<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ llm - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ llm - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 18 May 2026 22:34:35 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/llm/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ AI Paper Review: Language Models are Few-Shot Learners (GPT-3) ]]>
                </title>
                <description>
                    <![CDATA[ After GPT-2, it became clear that language models could do much more than researchers originally expected. Simply training a model to predict the next word had already started producing surprising abi ]]>
                </description>
                <link>https://www.freecodecamp.org/news/ai-paper-review-language-models-are-few-shot-learners-gpt-3/</link>
                <guid isPermaLink="false">6a0b76a04e81b730489aea6f</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ nlp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mohammed Fahd Abrah ]]>
                </dc:creator>
                <pubDate>Mon, 18 May 2026 20:29:20 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/9fd8e279-ebf3-4662-b204-737dd38b7648.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>After GPT-2, it became clear that language models could do much more than researchers originally expected. Simply training a model to predict the next word had already started producing surprising abilities like translation, summarization, and question answering without task-specific training.</p>
<p>But there was still a major limitation. Even though GPT-2 could generalize across tasks, it still struggled to adapt reliably. Performance often depended on carefully written prompts, and for many real-world applications, fine-tuning was still necessary. AI systems were becoming more flexible, but they still were not truly learning tasks from context the way humans do.</p>
<p>Then GPT-3 pushed the idea much further. Instead of asking whether language models could perform tasks without fine-tuning, the paper explored something even more ambitious:</p>
<p>What happens if we scale language models to an extreme size? The answer surprised almost everyone in the AI community.</p>
<p>GPT-3 showed that a sufficiently large language model could often learn new tasks directly from examples inside the prompt itself. No retraining. No gradient updates. Just a few demonstrations written in natural language.</p>
<p>For example, if you showed the model a few English-to-French translations, it could continue the pattern correctly for a new sentence. If you gave it examples of questions and answers, it could often infer the task immediately and generate reasonable responses.</p>
<p>This became known as <em>few-shot learning</em> and <em>in-context learning</em>.</p>
<p>More importantly, GPT-3 suggested a completely different way of interacting with AI systems. Instead of training a separate model for every task, the same model could dynamically adapt depending on the instructions and examples it received.</p>
<p>That idea eventually became the foundation for modern AI systems like ChatGPT.</p>
<p>Now, like many influential AI papers, the GPT-3 paper can be difficult to read because of its scale, technical experiments, and long benchmark evaluations. So in this article, I’ll break everything down in a clear and practical way.</p>
<p>We’ll explore what problem the paper was trying to solve, how few-shot learning works, why scaling became so important, how GPT-3 was trained, and why this paper fundamentally changed the direction of modern AI research.</p>
<p>By the end, you should understand the core ideas behind GPT-3 and why this paper became one of the most important milestones in the history of large language models LLM.</p>
<h2 id="heading-paper-overview">Paper Overview</h2>
<p>In this article, we’ll review the paper <a href="https://arxiv.org/pdf/2005.14165"><em>Language Models are Few-Shot Learners</em></a> by Tom Brown et al. from Open AI.</p>
<p>This paper introduced GPT-3 and demonstrated something that changed the direction of modern AI research: large language models could learn tasks directly from prompts and examples without task-specific fine-tuning like the methodology of GPT-1.</p>
<p>Instead of retraining the model for every new task, GPT-3 could often adapt dynamically through natural language instructions, one-shot examples, or few-shot prompting.</p>
<p>The paper also introduced the idea of <em>in-context learning</em>, where the model effectively learns from patterns inside the prompt itself during inference.</p>
<p>Here’s the original paper if you want to explore it directly: <a href="https://arxiv.org/pdf/2005.14165"><em>Language Models are Few-Shot Learners (PDF)</em></a></p>
<p>And here’s a quick infographic of what we’ll cover throughout this review:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/871201a8-de4c-4a1c-8b75-4bab09fdb1fc.png" alt="GPT-3 Quick Insight" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<h2 id="heading-table-of-content">Table of Content:</h2>
<ul>
<li><p><a href="#heading-executive-summary">Executive Summary</a></p>
</li>
<li><p><a href="#heading-goals-of-the-paper">Goals of the Paper</a></p>
</li>
<li><p><a href="#heading-core-idea">Core Idea</a></p>
</li>
<li><p><a href="#heading-methodology">Methodology</a></p>
</li>
<li><p><a href="#heading-fine-tuning-vs-zero-shot-vs-few-shot">Fine-tuning vs Zero-Shot vs Few-Shot</a></p>
</li>
<li><p><a href="#heading-model-architecture">Model Architecture</a></p>
</li>
<li><p><a href="#heading-experiments">Experiments</a></p>
</li>
<li><p><a href="#heading-key-findings">Key Findings</a></p>
</li>
<li><p><a href="#heading-task-specific-observations">Task-Specific Observations</a></p>
</li>
<li><p><a href="#heading-generalization-vs-memorization">Generalization vs Memorization</a></p>
</li>
<li><p><a href="#heading-discussion">Discussion</a></p>
</li>
<li><p><a href="#heading-limitations">Limitations</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-final-insight">Final Insight</a></p>
</li>
<li><p><a href="#heading-gpt-1-vs-gpt-2-vs-gpt-3-key-differences">GPT-1 vs GPT-2 vs GPT-3: Key Differences</a></p>
</li>
<li><p><a href="#heading-pytorch-implementations-of-the-gpt-architecture-evolution">PyTorch Implementations of the GPT Architecture Evolution</a></p>
</li>
<li><p><a href="#heading-resources">Resources:</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this breakdown, it helps to already be familiar with a few foundational ideas.</p>
<p>Reading the previous reviews in this series will be especially helpful:</p>
<ul>
<li><p><a href="https://www.freecodecamp.org/news/ai-paper-review-improving-language-understanding-by-generative-pre-training-gpt-1/"><em>AI Paper Review: Improving Language Understanding by Generative Pre-Training (GPT-1)</em></a></p>
</li>
<li><p><a href="https://www.freecodecamp.org/news/ai-paper-review-language-models-are-unsupervised-multitask-learners-gpt-2/"><em>AI Paper Review: Language Models are Unsupervised Multitask Learners (GPT-2)</em></a></p>
</li>
</ul>
<p>GPT-3 directly builds on many of the ideas introduced in those earlier papers, especially pre-training, zero-shot learning, and large-scale language modeling.</p>
<p>It also helps to have:</p>
<ul>
<li><p>A general understanding of natural language processing (NLP) and how machines work with text</p>
</li>
<li><p>A high-level idea of what a Transformer model is (you do not need deep mathematical details)</p>
</li>
<li><p>Familiarity with supervised learning, unsupervised learning, and zero-shot learning</p>
</li>
<li><p>A basic understanding of prompts and how language models generate text</p>
</li>
<li><p>General machine learning concepts like training data, parameters, scaling, and inference</p>
</li>
</ul>
<p>You do not need to be an AI researcher to follow this article, though.</p>
<p>I’ll keep the explanations practical and intuitive, focusing more on understanding the core ideas behind GPT-3 rather than getting lost in dense mathematical details or academic terminology.</p>
<h2 id="heading-executive-summary"><strong>Executive Summary</strong></h2>
<p>Before GPT-3, models like GPT-2 had already shown something surprising: a language model trained only to predict the next word could still perform many tasks it was never directly trained for. Translation, summarization, question answering somehow these abilities started appearing naturally as models became larger.</p>
<p>But there was still a limitation.</p>
<p>Even with GPT-2, strong performance often depended on careful prompting or additional fine-tuning. In practice, most NLP systems still followed the same pattern: train a large model first, then retrain or fine-tune it separately for every new task.</p>
<p>GPT-3 challenges that entire workflow.</p>
<p>According to the authors, if a language model becomes large enough, it can begin learning tasks directly from context alone. Instead of updating the model’s parameters, you simply show it a few examples inside the prompt, and the model continues the pattern.</p>
<p>This idea is what the paper calls <em>few-shot learning</em>.</p>
<p>For example, rather than training a separate translation model, you could write something like:</p>
<ul>
<li><p>dog → chien</p>
</li>
<li><p>cat → chat</p>
</li>
<li><p>house → ?</p>
</li>
</ul>
<p>And GPT-3 would often continue with the correct answer: <em>maison</em>.</p>
<p>What makes this important is that the model is not learning through gradient updates during inference. There is no retraining happening in the traditional sense. The learning happens inside the context window itself, through the examples provided in the prompt.</p>
<p>This marks a major shift in how language models are used.</p>
<p>Instead of building a specialized system for every task, GPT-3 suggests that a single sufficiently large model can adapt dynamically just by reading instructions and examples. The paper refers to this behavior as <em>in-context learning</em>, and much of GPT-3’s contribution revolves around showing how powerful this idea becomes at scale.</p>
<h2 id="heading-goals-of-the-paper"><strong>Goals of the Paper</strong></h2>
<p>According to the authors, one of the biggest limitations of existing NLP systems is that they depend too heavily on task-specific training. Even though models had become increasingly powerful by the time GPT-3 was introduced, most systems still required a separate fine-tuning process for every new task.</p>
<p>In practice, this created several problems.</p>
<p>First, every task needed labeled data. If you wanted a model to summarize articles, answer questions, classify sentiment, or translate text, you usually needed thousands, or sometimes millions of carefully prepared examples. Collecting that data was expensive, time-consuming, and often unrealistic for smaller or niche tasks.</p>
<p>Second, every new capability required additional training. Even when the underlying model was already pretrained on massive amounts of text, developers still had to retrain or fine-tune it again and again for specific use cases.</p>
<p>The paper argues that this workflow is fundamentally inefficient. More importantly, the authors point out that it does not resemble how humans learn. Humans can often understand a task after seeing only a few demonstrations or simple instructions. We do not usually need thousands of labeled examples to figure out what is being asked.</p>
<p>This becomes the central question behind GPT-3:</p>
<p>Can a language model learn new tasks directly from context instead of relying on parameter updates and task-specific retraining?</p>
<p>That question drives nearly every experiment in the paper. Rather than testing whether GPT-3 can master one carefully optimized benchmark, the authors are exploring something broader: whether scaling language models can produce systems that adapt dynamically just from prompts, examples, and natural language instructions.</p>
<h2 id="heading-core-idea"><strong>Core Idea</strong></h2>
<p>At its core, GPT-3 is still built around the same fundamental idea used in GPT-2: train a language model to predict the next token in a sequence. The training objective itself is surprisingly simple. Given some text, the model learns to guess what comes next, one token at a time.</p>
<p>On the surface, GPT-3 may look like nothing more than a much larger version of GPT-2. And in some ways, that is true. The model scales dramatically in size, growing to 175 billion parameters, and it is trained on a far larger and more diverse dataset gathered from sources like Common Crawl, WebText, books, and Wikipedia.</p>
<p>But the paper argues that something more interesting begins to happen as language models scale.</p>
<p>Instead of simply memorizing text patterns better, GPT-3 starts showing the ability to learn tasks directly from prompts. When the model sees examples inside the input itself, it can often continue the pattern correctly without any additional training or parameter updates.</p>
<p>For example, if the prompt contains a few question-answer pairs or translation examples, GPT-3 can infer the structure of the task and generate similar outputs for new inputs. In other words, the prompt becomes a temporary learning environment.</p>
<p>This is the key conceptual shift in the paper.</p>
<p>Traditional machine learning usually separates training from inference. First the model learns by updating its weights, then later it is deployed to make predictions. GPT-3 blurs that boundary. The model still learns during pretraining, of course, but during inference it can also adapt behavior dynamically based on the context it receives.</p>
<p>The authors describe this behavior as <em>in-context learning</em>.</p>
<p>What makes this idea important is that the model is not retrained for each task. There are no gradient updates happening while the prompt is processed. Instead, GPT-3 learns from the examples embedded inside the context window itself.</p>
<p>This marks a subtle but important change in how we think about language models. The prompt is no longer just an input. It effectively becomes a lightweight interface for teaching the model what to do.</p>
<h2 id="heading-methodology"><strong>Methodology</strong></h2>
<p>One reason GPT-3 became so influential is that the underlying training process is actually very familiar. Unlike many research papers that introduce entirely new architectures or complicated learning algorithms, GPT-3 mostly builds on ideas that already existed before it. The difference is how aggressively those ideas are scaled.</p>
<p>According to the authors, the core training objective remains standard autoregressive language modeling. In simple terms, the model reads text and repeatedly learns to predict the next token in the sequence. This is the same general approach used in GPT-2.</p>
<p>The process itself is conceptually straightforward:</p>
<ul>
<li><p>Train a very large Transformer model</p>
</li>
<li><p>Feed it enormous amounts of internet text</p>
</li>
<li><p>Optimize it to predict the next word over and over again</p>
</li>
</ul>
<p>What changes dramatically is the scale.</p>
<p>GPT-3 is trained on hundreds of billions of tokens collected from sources such as Common Crawl, WebText, books, and Wikipedia. The paper also explains that OpenAI filtered and cleaned large portions of the Common Crawl dataset to improve quality and reduce duplication.</p>
<p>But the most important part of the methodology is not just how the model is trained. It is how the model is <em>used after training</em>.</p>
<p>Traditionally, NLP systems relied heavily on fine-tuning. After pretraining a language model, developers would train it again on a smaller labeled dataset for each individual task. GPT-3 experiments with a different approach entirely.</p>
<p>Instead of retraining the model, tasks are described directly inside the prompt.</p>
<p>The paper studies three main settings:</p>
<ul>
<li><p><em>Zero-shot learning</em>: the model receives only a natural language instruction</p>
</li>
<li><p><em>One-shot learning</em>: the model receives a single example of the task</p>
</li>
<li><p><em>Few-shot learning</em>: the model receives several examples before solving a new case</p>
</li>
</ul>
<p>For example, a translation prompt might look like this:</p>
<p>dog → chien<br>cat → chat<br>house → ?</p>
<p>GPT-3 then continues the pattern and predicts:</p>
<p>maison</p>
<p>What makes this remarkable is that no retraining happens during this process. The model’s weights remain completely unchanged. It is simply using the information inside the prompt to infer what kind of task is being requested.</p>
<p>In practice, this transforms the prompt into something much more powerful than an ordinary input. It becomes a temporary workspace where the model can recognize patterns, adapt behavior, and apply learned knowledge dynamically.</p>
<p>The paper repeatedly emphasizes that this behavior emerges through scale rather than task-specific engineering. GPT-3 is not trained separately for translation, summarization, reasoning, or question answering. Instead, the same general language modelinqag objective appears to produce all of these abilities when the model becomes sufficiently large.</p>
<h2 id="heading-fine-tuning-vs-zero-shot-vs-few-shot"><strong>Fine-tuning vs Zero-Shot vs Few-Shot</strong></h2>
<table style="min-width:100px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>Aspect</strong></p></td><td><p><strong>Fine-Tuning</strong></p></td><td><p><strong>Zero-Shot Learning</strong></p></td><td><p><strong>Few-Shot Learning</strong></p></td></tr><tr><td><p><strong>Definition</strong></p></td><td><p>The model is additionally trained on labeled data for a specific task</p></td><td><p>The model performs a task using only instructions, without examples</p></td><td><p>The model learns the task from a small number of examples inside the prompt</p></td></tr><tr><td><p><strong>Training Requirement</strong></p></td><td><p>Requires supervised task-specific datasets</p></td><td><p>No task-specific training or examples</p></td><td><p>No retraining, but requires a few demonstrations in the prompt</p></td></tr><tr><td><p><strong>How Tasks Are Given</strong></p></td><td><p>Through a separate training phase</p></td><td><p>Through natural language instructions</p></td><td><p>Through instructions plus a few input-output examples</p></td></tr><tr><td><p><strong>Learning Process</strong></p></td><td><p>Model weights are updated during training</p></td><td><p>No weight updates</p></td><td><p>No weight updates; learning happens inside the context window</p></td></tr><tr><td><p><strong>Flexibility</strong></p></td><td><p>Usually specialized for one task</p></td><td><p>Highly flexible across many tasks</p></td><td><p>Flexible while still benefiting from demonstrations</p></td></tr><tr><td><p><strong>Adaptability</strong></p></td><td><p>Requires retraining for new tasks</p></td><td><p>Adapts instantly through prompting</p></td><td><p>Adapts quickly from contextual examples</p></td></tr><tr><td><p><strong>Data Dependency</strong></p></td><td><p>Depends heavily on labeled datasets</p></td><td><p>Depends mostly on pretraining knowledge</p></td><td><p>Depends on both pretraining and prompt examples</p></td></tr><tr><td><p><strong>Performance</strong></p></td><td><p>Often strongest on narrow benchmark tasks</p></td><td><p>Usually weaker than fine-tuning</p></td><td><p>Often much stronger than zero-shot and sometimes close to fine-tuning</p></td></tr><tr><td><p><strong>Scalability Across Tasks</strong></p></td><td><p>Expensive and difficult to scale</p></td><td><p>Extremely scalable</p></td><td><p>Scalable without retraining</p></td></tr><tr><td><p><strong>Compute Cost</strong></p></td><td><p>High because every task may require new training</p></td><td><p>Low during usage</p></td><td><p>Low during usage</p></td></tr><tr><td><p><strong>Example</strong></p></td><td><p>Fine-tune a model on a sentiment analysis dataset</p></td><td><p>“Classify the sentiment of this sentence”</p></td><td><p>“Positive: I loved the movie. Negative: The film was boring. Sentence: The story was amazing →”</p></td></tr><tr><td><p><strong>Main Strength</strong></p></td><td><p>High accuracy on carefully trained tasks</p></td><td><p>Simplicity and broad generalization</p></td><td><p>Strong balance between flexibility and performance</p></td></tr><tr><td><p><strong>Main Weakness</strong></p></td><td><p>Poor scalability across many tasks</p></td><td><p>Can misunderstand task format or intent</p></td><td><p>Sensitive to prompt quality and example selection</p></td></tr><tr><td><p><strong>Most Associated With</strong></p></td><td><p>Traditional NLP systems, GPT-1 era</p></td><td><p>GPT-2 style prompting</p></td><td><p>GPT-3 and in-context learning</p></td></tr><tr><td><p><strong>Core Idea</strong></p></td><td><p>Train specifically for each task</p></td><td><p>Infer the task from instructions</p></td><td><p>Infer the task from examples in context</p></td></tr></tbody></table>

<h2 id="heading-model-architecture"><strong>Model Architecture</strong></h2>
<p>Architecturally, GPT-3 does not introduce a radically new design. In fact, one of the most interesting aspects of the paper is that the core architecture is almost identical to GPT-2. OpenAI continues using a decoder-only Transformer model trained with an autoregressive objective.</p>
<p>At a high level, the Transformer architecture processes text using a mechanism called <em>attention</em>. Instead of reading words strictly one at a time like older recurrent models, Transformers can look across the entire sequence and determine which words are most relevant to each other.</p>
<p>More specifically, GPT-3 relies on <em>self-attention</em>, which allows the model to weigh different parts of the context while generating text. This helps the model capture long-range relationships between words, sentences, and ideas.</p>
<p>The model is also <em>autoregressive</em>, meaning it generates text sequentially by predicting the next token based on everything that came before it. This next-token prediction objective remains the foundation of GPT-3, just as it was for GPT-2.</p>
<p>So if the architecture is mostly the same, what actually changed?</p>
<p>The answer is scale.</p>
<p>GPT-3 dramatically increases the size of the model, the amount of training data, and the computational resources used during training. The largest version of GPT-3 contains 175 billion parameters, making it far larger than GPT-2’s 1.5 billion parameter model.</p>
<p>The paper also experiments with multiple model sizes ranging from 125 million parameters all the way to 175 billion. This was important because the authors wanted to study how capabilities evolve as models grow larger.</p>
<p>The architecture includes:</p>
<ul>
<li><p>A decoder-only Transformer design</p>
</li>
<li><p>A context window of 2048 tokens</p>
</li>
<li><p>Multiple model scales trained under similar objectives</p>
</li>
<li><p>Attention mechanisms that allow the model to process contextual relationships efficiently</p>
</li>
</ul>
<p>One of the paper’s most important observations is that performance improves smoothly as scale increases. Larger models consistently perform better across a wide range of tasks, including translation, question answering, reasoning, and few-shot learning.</p>
<p>This idea becomes central to the entire GPT-3 paper.</p>
<p>Rather than relying on handcrafted task-specific systems, the authors suggest that many advanced capabilities emerge naturally when language models become sufficiently large and are trained on enough diverse data. In other words, scaling itself starts acting like a research strategy.</p>
<p>What makes this shift important is that GPT-3 does not achieve its results through complicated architectural innovations. The paper’s argument is much simpler, and in some ways more surprising:</p>
<p>A relatively standard Transformer architecture, when scaled aggressively enough, begins to display entirely new behaviors.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/4ab1a945-4379-4f2a-b8a5-3dd15ddbcebb.png" alt="Transformer-Decoder-Architecture" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><strong>Note:</strong> The original figure illustrates the complete Transformer architecture (Encoder–Decoder) from <em>Attention Is All You Need</em>. For clarity and relevance to GPT-style models, the image used here was cropped to focus only on the decoder side of the architecture, since GPT models are based on a decoder-only Transformer design.</p>
<p><strong>Reference:</strong> Brownlee, J. <a href="https://machinelearningmastery.com/encoders-and-decoders-in-transformer-models/?utm_source=chatgpt.com">Encoders and Decoders in Transformer Models</a> Machine Learning Mastery.</p>
<h2 id="heading-experiments"><strong>Experiments</strong></h2>
<p>To understand whether GPT-3 could truly learn from context alone, the authors evaluated the model across a very broad range of NLP tasks. Rather than focusing on a single benchmark, the paper tests whether the same pretrained model can adapt to many different kinds of problems using only prompts and examples.</p>
<p>The experiments cover a wide variety of domains, including:</p>
<ul>
<li><p>Language modeling and text completion</p>
</li>
<li><p>Question answering</p>
</li>
<li><p>Translation between languages</p>
</li>
<li><p>Reading comprehension</p>
</li>
<li><p>Commonsense reasoning</p>
</li>
<li><p>Winograd-style reasoning tasks</p>
</li>
<li><p>Cloze and sentence completion tasks</p>
</li>
<li><p>Synthetic reasoning problems such as arithmetic and word manipulation</p>
</li>
</ul>
<p>What makes these experiments especially important is the evaluation setup itself.</p>
<p>Instead of fine-tuning GPT-3 separately for each benchmark, the model is tested entirely through prompting. The authors evaluate GPT-3 in three different settings:</p>
<ul>
<li><p><em>Zero-shot learning</em>, where the model receives only a task description</p>
</li>
<li><p><em>One-shot learning</em>, where it receives a single example</p>
</li>
<li><p><em>Few-shot learning</em>, where several demonstrations are included inside the prompt</p>
</li>
</ul>
<p>For example, in translation tasks, the prompt may contain a few English-to-French examples before asking the model to continue the pattern. In question-answering tasks, the model might see several example questions and answers before attempting a new one.</p>
<p>Importantly, the model’s parameters never change during these evaluations. There are no gradient updates, no retraining steps, and no task-specific optimization. GPT-3 performs every task using the exact same pretrained weights.</p>
<p>This is one of the paper’s biggest departures from traditional NLP systems.</p>
<p>At the time, most state-of-the-art models achieved strong benchmark results through supervised fine-tuning on carefully prepared datasets. GPT-3 instead tests whether a single large language model can generalize across tasks simply by understanding patterns inside prompts.</p>
<p>The paper also evaluates how performance changes as model size increases. OpenAI trained multiple versions of GPT-3, ranging from 125 million parameters up to 175 billion parameters, then compared how scaling affected zero-shot, one-shot, and few-shot behavior.</p>
<p>According to the authors, larger models become noticeably better at using contextual information. Few-shot learning improves especially strongly with scale, suggesting that bigger models are not just memorizing more information. They are becoming better at adapting to new tasks dynamically.</p>
<h2 id="heading-key-findings"><strong>Key Findings</strong></h2>
<p>This is the section where GPT-3 stops feeling like “just a bigger language model” and starts looking like something fundamentally different.</p>
<p>According to the paper, one of the clearest patterns across nearly all experiments is that performance improves consistently as model size increases. As GPT-3 scales from millions of parameters to hundreds of billions, the model becomes dramatically better at understanding prompts, adapting to context, and performing tasks it was never explicitly trained for.</p>
<p>But the most surprising result is not simply higher benchmark scores.</p>
<p>The real breakthrough is that <em>few-shot learning actually works at scale</em>.</p>
<p>Across many tasks, GPT-3’s few-shot performance approaches strong fine-tuned systems, and in some cases even matches or surpasses them. This is remarkable because GPT-3 achieves these results without updating its weights for individual tasks. Everything happens through prompting alone.</p>
<p>One of the strongest examples appears in question answering benchmarks.</p>
<p>On TriviaQA, GPT-3 improves significantly as more examples are provided in the prompt. The paper reports that zero-shot performance is already competitive, but one-shot and few-shot prompting push results even further, eventually reaching or exceeding some state-of-the-art fine-tuned systems in the same closed-book setting.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/1b4bfb72-6cbe-4af9-ba1c-5ddb1afa47eb.png" alt="ZeroShot-OneShot-FewShot learning" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Source: Brown et al. (2020), <em>Language Models are Few-Shot Learners</em>, Figure 1.2.</p>
<p>The same pattern appears repeatedly throughout the paper:</p>
<ul>
<li><p>Few-shot prompting consistently outperforms zero-shot prompting</p>
</li>
<li><p>Larger models make better use of contextual examples</p>
</li>
<li><p>Scaling improves not only accuracy, but adaptability itself</p>
</li>
</ul>
<p>This last point is especially important.</p>
<p>The paper suggests that scaling does more than help the model memorize facts or generate more fluent text. As models become larger, they appear to develop stronger <em>in-context learning</em> abilities. In other words, bigger models become better at inferring patterns and task structures directly from prompts.</p>
<p>The authors even observe that the gap between zero-shot and few-shot performance grows with model size. Smaller models struggle to learn effectively from prompts, while larger models can often infer the task from only a handful of examples.</p>
<p>What makes this finding historically important is that it changes how researchers think about capability growth in AI systems.</p>
<p>Before GPT-3, scaling was often viewed mainly as a way to improve existing performance metrics. GPT-3 introduces a different possibility: that entirely new behaviors can emerge as models become sufficiently large.</p>
<p>This is why the paper became so influential. It was not just reporting better benchmark numbers. It was presenting evidence that scale itself can unlock qualitatively new forms of learning behavior.</p>
<h2 id="heading-task-specific-observations"><strong>Task-Specific Observations</strong></h2>
<p>When you look beyond the headline results, the paper reveals something more nuanced about GPT-3: its abilities are highly uneven. The model performs surprisingly well in some areas, yet still struggles badly in others.</p>
<p>GPT-3 shows particularly strong performance on tasks that align closely with pattern recognition and language continuation.</p>
<p>Translation is one notable example. While GPT-3 was never trained specifically as a translation system, the model can still produce impressive results when given a few examples in the prompt. According to the paper, few-shot translation performance improves substantially as model size increases, especially when translating into English.</p>
<p>The model also performs well on question answering benchmarks, especially in closed-book settings where the answer must come directly from information stored inside the model’s parameters. Tasks like TriviaQA show strong gains as GPT-3 moves from zero-shot to few-shot prompting.</p>
<p>Text completion and cloze-style tasks are another major strength. GPT-3 demonstrates a strong ability to continue patterns, complete paragraphs, and infer missing words from context. On datasets like LAMBADA, the few-shot setup produces especially large improvements.</p>
<p>But the paper is also careful about documenting weaknesses.</p>
<p>GPT-3 struggles noticeably on certain reasoning-heavy benchmarks, particularly tasks involving natural language inference. Datasets like ANLI remain difficult even for the largest model.</p>
<p>Some reading comprehension tasks also expose limitations. In several cases, GPT-3 generates answers that sound plausible but fail to demonstrate deep understanding of the passage. This becomes a recurring theme throughout the paper: fluent language generation does not always mean reliable reasoning.</p>
<p>One of the most interesting observations is how sensitive GPT-3 is to prompt design.</p>
<p>Performance often changes dramatically depending on how examples are written, formatted, or ordered inside the context window. In many tasks, adding just a few demonstrations significantly improves accuracy.</p>
<p>This suggests something important about how GPT-3 operates.</p>
<p>The model is not simply retrieving fixed knowledge from memory. Instead, it relies heavily on contextual cues to infer what kind of behavior is expected. Small prompt changes can reshape the model’s interpretation of the task itself.</p>
<p>In practice, this paper helped introduce an entirely new idea to the AI community: that <em>how you ask the model</em> can matter almost as much as the model itself.</p>
<p>That insight eventually evolves into what we now call <em>prompt engineering</em>.</p>
<h2 id="heading-generalization-vs-memorization"><strong>Generalization vs Memorization</strong></h2>
<p>One of the biggest questions surrounding GPT-3 is whether the model is genuinely learning useful patterns, or simply memorizing enormous portions of the internet.</p>
<p>This concern becomes especially important because GPT-3 is trained on massive web-scale datasets, including Common Crawl. With a model this large, it is reasonable to ask whether strong benchmark performance comes from real generalization or from accidentally seeing parts of the evaluation data during training.</p>
<p>The authors take this issue seriously and dedicate an entire section of the paper to studying what they call <em>data contamination</em>.</p>
<p>According to the paper, OpenAI searched for overlaps between the training data and benchmark datasets used during evaluation. They discovered that some contamination did exist. In other words, portions of certain evaluation datasets appeared somewhere inside the model’s training corpus.</p>
<p>However, the authors argue that this overlap is not large enough to fully explain GPT-3’s results.</p>
<p>For many benchmarks, performance improvements remain consistent even after accounting for contamination effects. The paper also notes that some tasks specifically designed to test adaptation and reasoning still show strong few-shot behavior despite being unlikely to appear directly in the training data.</p>
<p>Another important observation is that GPT-3 still <em>underfits</em> the training data. This means the model has not perfectly memorized everything it has seen, even after extremely large-scale training.</p>
<p>That detail matters because it suggests the model is learning statistical structures and linguistic patterns rather than storing an exact copy of the dataset.</p>
<p>Of course, memorization does still happen to some extent. Large language models can reproduce fragments of training text, especially when rare or repeated data appears frequently during training. The paper does not deny this. Instead, the authors argue that memorization alone cannot explain GPT-3’s broad performance across translation, reasoning, question answering, and in-context learning tasks.</p>
<p>In practice, the evidence points toward something more complex.</p>
<p>GPT-3 appears to absorb patterns, relationships, and task structures from large-scale text data, then reuse those patterns flexibly in new contexts. That is very different from simply copying stored answers.</p>
<p>This distinction becomes one of the central debates in modern AI research. GPT-3 forced researchers to think more carefully about what it actually means for a language model to “understand” something, and where the boundary lies between memorization, pattern recognition, and genuine generalization.</p>
<h2 id="heading-discussion"><strong>Discussion</strong></h2>
<p>This is the point in the paper where the broader implications of GPT-3 start becoming clear.</p>
<p>According to the authors, large language models may be doing something more general than simply predicting text. By training on enormous amounts of language data, the model appears to learn patterns associated with tasks themselves.</p>
<p>That idea changes how we think about language modeling.</p>
<p>Traditionally, NLP systems were designed around explicit supervision. If you wanted a model to translate text, answer questions, summarize documents, or classify sentiment, you trained it specifically for that task using labeled examples.</p>
<p>GPT-3 suggests a different possibility.</p>
<p>The paper argues that many tasks are already implicitly embedded inside natural language data. During pretraining, the model encounters countless examples of explanations, translations, conversations, reasoning patterns, instructions, and question-answer pairs scattered across the internet. As scale increases, the model begins learning these behaviors indirectly.</p>
<p>In practice, this means the model does not always require explicit retraining to perform a new task. Instead, prompts and examples can activate behaviors the model has already absorbed during pretraining.</p>
<p>This is why prompting becomes so powerful in GPT-3.</p>
<p>The prompt is not merely providing information. It is guiding the model toward a behavior pattern that already exists somewhere inside its learned representations.</p>
<p>At the same time, the authors are careful not to overstate the results.</p>
<p>Throughout the paper, they repeatedly acknowledge that GPT-3 is still inconsistent. Some outputs are remarkably convincing, while others are obviously incorrect, nonsensical, or logically flawed.</p>
<p>This becomes one of GPT-3’s defining characteristics.</p>
<p>The model often sounds far more confident than it actually is. It can generate fluent explanations and persuasive answers even when the underlying reasoning is weak or factually wrong. In some tasks, especially deeper reasoning and reading comprehension benchmarks, GPT-3 still struggles significantly.</p>
<p>So the paper does not present GPT-3 as a solved form of intelligence.</p>
<p>Instead, it presents evidence that scaling language models unlocks new capabilities that were previously weak or absent. The results are impressive enough to suggest a major shift in direction, but not strong enough to eliminate the need for further research.</p>
<p>That balance is part of what makes the paper influential. It is ambitious, but also surprisingly honest about the limitations that still remain.</p>
<h2 id="heading-limitations"><strong>Limitations</strong></h2>
<p>One reason the GPT-3 paper remained credible despite the excitement surrounding it is that the authors were unusually open about the model’s weaknesses. The paper does not claim that few-shot learning solves NLP, nor does it pretend that GPT-3 works reliably on every task.</p>
<p>In many cases, traditional fine-tuned systems still perform better.</p>
<p>Although GPT-3 achieves impressive few-shot results across a wide range of benchmarks, the model continues to struggle on several reasoning-heavy tasks, especially natural language inference and certain reading comprehension datasets.</p>
<p>The paper also emphasizes that GPT-3’s success depends heavily on scale. Smaller versions of the model show far weaker few-shot capabilities, while the strongest results appear only at extremely large parameter counts.</p>
<p>This creates a major practical problem.</p>
<p>Training GPT-3 required enormous computational resources, specialized infrastructure, and vast amounts of data. The largest model contains 175 billion parameters and was trained using large GPU clusters over massive datasets.</p>
<p>In practice, very few organizations in the world could realistically reproduce this work at the time.</p>
<p>The paper also discusses broader concerns around bias and fairness. Since GPT-3 learns from large internet datasets, it inevitably absorbs social biases, stereotypes, and problematic language patterns present in the data itself.</p>
<p>This becomes especially concerning because the model can generate highly convincing text. Incorrect or biased outputs may sound authoritative even when they are misleading or harmful.</p>
<p>Another issue the authors examine is <em>data contamination</em>. Because GPT-3 is trained on web-scale corpora, parts of benchmark datasets may accidentally appear in the training data. The paper investigates this directly and acknowledges that some overlap exists, although the authors argue that contamination alone does not explain the overall results.</p>
<p>There is also an environmental and economic cost to scaling models this aggressively.</p>
<p>Training systems at the scale of GPT-3 consumes enormous amounts of compute and energy, raising questions about sustainability and accessibility in AI research. As models become larger, cutting-edge progress increasingly depends on access to industrial-scale infrastructure.</p>
<p>This creates a tension that still exists today.</p>
<p>GPT-3 demonstrated that scaling works extraordinarily well, but it also highlighted how concentrated advanced AI research was becoming. The future of large language models was clearly promising, but also increasingly expensive.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>The paper ends with a surprisingly simple conclusion: scaling language models changes what they are capable of doing.</p>
<p>According to the authors, GPT-3 demonstrates that a sufficiently large language model can learn tasks directly from context without requiring gradient updates or task-specific fine-tuning.</p>
<p>That idea represents a major shift in the direction of NLP.</p>
<p>For years, the standard workflow in machine learning looked something like this:</p>
<ul>
<li><p>Pretrain a model</p>
</li>
<li><p>Fine-tune it for a specific task</p>
</li>
<li><p>Deploy the specialized system</p>
</li>
</ul>
<p>GPT-3 introduces a different paradigm.</p>
<p>Instead of retraining the model repeatedly for new tasks, the same pretrained model can often adapt through prompts alone. Instructions and examples inside the context window become enough to guide the model toward useful behavior.</p>
<p>In other words, the workflow starts looking more like this:</p>
<ul>
<li><p>Train once</p>
</li>
<li><p>Adapt dynamically through prompting</p>
</li>
</ul>
<p>What makes this important is not just convenience. It changes how researchers think about generalization itself.</p>
<p>The paper suggests that many capabilities traditionally associated with supervised learning can emerge naturally from large-scale language modeling. Translation, question answering, reasoning, summarization, and even task adaptation begin appearing inside a single unified system trained only with next-token prediction.</p>
<p>At the same time, the authors remain careful in their conclusions.</p>
<p>GPT-3 is clearly powerful, but it is not reliable enough to be considered a complete solution to intelligence or reasoning. The paper repeatedly acknowledges weaknesses involving logic, factual accuracy, bias, and consistency.</p>
<p>Still, the broader message is difficult to ignore.</p>
<p>GPT-3 showed that scaling language models does not simply improve fluency. It can produce entirely new behaviors that were weak or absent in smaller systems. That realization reshaped the trajectory of modern AI research and laid the foundation for the prompt-driven systems that would soon follow.</p>
<h2 id="heading-final-insight"><strong>Final Insight</strong></h2>
<p>If GPT-1 introduced the idea of large-scale pretraining followed by fine-tuning, and GPT-2 showed that language models could generalize surprisingly well without task-specific training, then GPT-3 pushes the idea even further.</p>
<p>It suggests that language models can begin learning <em>during inference itself</em>.</p>
<p>That is the real conceptual shift behind this paper.</p>
<p>Before GPT-3, most AI systems were still fundamentally task-specific. Even powerful pretrained models usually needed additional supervised training before they became useful for a particular application.</p>
<p>GPT-3 starts breaking that pattern.</p>
<p>Instead of building a separate model for translation, summarization, question answering, or reasoning, the same model can adapt dynamically depending on the prompt it receives. Examples inside the context window effectively become temporary instructions for behavior.</p>
<p>In practice, this moves AI systems away from narrow specialization and toward something more flexible:</p>
<ul>
<li><p>From task-specific systems</p>
</li>
<li><p>To general-purpose models that adapt on the fly</p>
</li>
</ul>
<p>What makes this especially important is that GPT-3 did not achieve this through complicated symbolic reasoning systems or handcrafted pipelines. The model was still trained using a relatively simple next-token prediction objective. Yet at sufficient scale, entirely new behaviors started emerging.</p>
<p>Looking back, this paper feels less like the end of the GPT series and more like the beginning of a new era.</p>
<p>Many ideas that now define modern AI trace directly back to GPT-3:</p>
<ul>
<li><p>Prompt engineering</p>
</li>
<li><p>Instruction-following systems</p>
</li>
<li><p>In-context learning</p>
</li>
<li><p>Conversational AI assistants</p>
</li>
<li><p>General-purpose foundation models</p>
</li>
</ul>
<p>And ultimately, systems like ChatGPT exist because GPT-3 demonstrated that prompting itself could become a powerful interface for interacting with intelligence.</p>
<p>That is why this paper became historically important.</p>
<p>It did not just scale language models. It changed how people imagined using them.</p>
<h2 id="heading-gpt-1-vs-gpt-2-vs-gpt-3-key-differences"><strong>GPT-1 vs GPT-2 vs GPT-3: Key Differences</strong></h2>
<table style="min-width:100px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>Aspect</strong></p></td><td><p><strong>GPT-1</strong></p></td><td><p><strong>GPT-2</strong></p></td><td><p><strong>GPT-3</strong></p></td></tr><tr><td><p><strong>Core Idea</strong></p></td><td><p>Pre-training followed by fine-tuning</p></td><td><p>Pre-training alone enables zero-shot behavior</p></td><td><p>Large-scale pre-training enables few-shot and in-context learning</p></td></tr><tr><td><p><strong>Training Approach</strong></p></td><td><p>Two-stage pipeline: pretrain then fine-tune</p></td><td><p>Single-stage language modeling</p></td><td><p>Same language modeling approach, but massively scaled</p></td></tr><tr><td><p><strong>Supervision</strong></p></td><td><p>Requires labeled data for downstream tasks</p></td><td><p>Can perform tasks without supervised fine-tuning</p></td><td><p>Can adapt from prompts and examples without retraining</p></td></tr><tr><td><p><strong>Task Handling</strong></p></td><td><p>Separate fine-tuning for each task</p></td><td><p>Tasks handled mainly through zero-shot prompts</p></td><td><p>Tasks handled through zero-shot, one-shot, and few-shot prompting</p></td></tr><tr><td><p><strong>Learning Style</strong></p></td><td><p>Learns representations, then specializes</p></td><td><p>Learns general language patterns</p></td><td><p>Learns to infer tasks directly from context</p></td></tr><tr><td><p><strong>Generalization</strong></p></td><td><p>Limited outside fine-tuned tasks</p></td><td><p>Stronger cross-task generalization</p></td><td><p>Much stronger contextual adaptation and in-context learning</p></td></tr><tr><td><p><strong>Prompt Usage</strong></p></td><td><p>Minimal importance</p></td><td><p>Prompts become useful</p></td><td><p>Prompts become central to system behavior</p></td></tr><tr><td><p><strong>Inference Behavior</strong></p></td><td><p>Mostly static after training</p></td><td><p>Can generalize during inference</p></td><td><p>Can adapt dynamically during inference</p></td></tr><tr><td><p><strong>Architecture</strong></p></td><td><p>Transformer (decoder-based)</p></td><td><p>Decoder-only Transformer</p></td><td><p>Decoder-only Transformer with large-scale scaling</p></td></tr><tr><td><p><strong>Model Size</strong></p></td><td><p>~117M parameters</p></td><td><p>Up to 1.5B parameters</p></td><td><p>Up to 175B parameters</p></td></tr><tr><td><p><strong>Context Window</strong></p></td><td><p>Smaller context length</p></td><td><p>Up to 1024 tokens</p></td><td><p>2048-token context window</p></td></tr><tr><td><p><strong>Training Data</strong></p></td><td><p>Books Corpus and curated datasets</p></td><td><p>WebText internet dataset</p></td><td><p>Massive multi-source dataset including Common Crawl, WebText, Books, and Wikipedia</p></td></tr><tr><td><p><strong>Key Capability</strong></p></td><td><p>Transfer learning</p></td><td><p>Zero-shot learning</p></td><td><p>Few-shot and in-context learning</p></td></tr><tr><td><p><strong>Performance Style</strong></p></td><td><p>Strong after fine-tuning</p></td><td><p>Strong without task-specific training</p></td><td><p>Often competitive with fine-tuned systems using prompts alone</p></td></tr><tr><td><p><strong>Scaling Importance</strong></p></td><td><p>Moderate</p></td><td><p>Important</p></td><td><p>Central research strategy of the paper</p></td></tr><tr><td><p><strong>Main Limitation</strong></p></td><td><p>Requires labeled datasets and retraining</p></td><td><p>Weak reasoning and inconsistent zero-shot behavior</p></td><td><p>Extremely expensive compute requirements and persistent reasoning limitations</p></td></tr><tr><td><p><strong>Main Contribution</strong></p></td><td><p>Introduced modern NLP pre-training paradigm</p></td><td><p>Demonstrated multitask zero-shot behavior</p></td><td><p>Demonstrated emergent in-context learning at scale</p></td></tr><tr><td><p><strong>Historical Impact</strong></p></td><td><p>Foundation of modern Transformer NLP</p></td><td><p>Shift toward general-purpose language models</p></td><td><p>Foundation for prompt-driven AI systems and modern LLM applications</p></td></tr><tr><td><p><strong>What Changed in the Field</strong></p></td><td><p>Pre-training became standard</p></td><td><p>Prompting became viable</p></td><td><p>Prompting became the primary interface for AI systems</p></td></tr><tr><td><p><strong>Legacy</strong></p></td><td><p>Inspired modern transfer learning pipelines</p></td><td><p>Inspired large-scale generative models</p></td><td><p>Directly influenced ChatGPT, instruction tuning, and foundation models</p></td></tr></tbody></table>

<h2 id="heading-pytorch-implementations-of-the-gpt-architecture-evolution">PyTorch Implementations of the GPT Architecture Evolution</h2>
<p><strong>GPT-1: Pre-training + Fine-Tuning Architecture</strong></p>
<pre><code class="language-python">class GPT1(nn.Module):
    def __init__(self, vocab_size, d_model, n_layers):
        super().__init__()

        self.token_embedding = nn.Embedding(vocab_size, d_model)
        self.position_embedding = nn.Embedding(512, d_model)

        self.transformer_blocks = nn.ModuleList([
            TransformerBlock(d_model)
            for _ in range(n_layers)
        ])

        self.ln_f = nn.LayerNorm(d_model)

        # Language modeling head
        self.lm_head = nn.Linear(d_model, vocab_size)

    def forward(self, input_ids):
        positions = torch.arange(input_ids.size(1))

        x = (
            self.token_embedding(input_ids)
            + self.position_embedding(positions)
        )

        for block in self.transformer_blocks:
            x = block(x)

        x = self.ln_f(x)

        logits = self.lm_head(x)

        return logits
</code></pre>
<p><code>GPT1</code> inherits from <code>nn.Module</code>, which is the base class used to build neural networks in PyTorch. The constructor <code>(init)</code> defines all trainable layers used by the model.</p>
<p><code>nn.Embedding(vocab_size, d_model)</code> creates a learnable lookup table that converts token IDs into dense vectors. Each token in the vocabulary is mapped to a vector of size <code>d_model</code>.</p>
<p>The positional embedding layer adds information about token order. Since Transformers process tokens in parallel, they need explicit positional information to understand sequence structure.</p>
<p><code>nn.ModuleList([...])</code> stores multiple <code>Transformer blocks</code> while ensuring PyTorch properly tracks their parameters during training. Each TransformerBlock typically contains masked self-attention and a feed-forward network.</p>
<p><code>nn.LayerNorm(d_model)</code> applies layer normalization before the output projection. This helps stabilize training and improves gradient flow in deep Transformer architectures.</p>
<p>The language modeling head <code>(nn.Linear)</code> projects the hidden representations back into vocabulary space. The output size equals <code>vocab_size</code>, producing prediction scores for every possible next token.</p>
<p>Inside the <code>forward()</code> method, <code>input_ids.size(1)</code> retrieves the sequence length, and <code>torch.arange(...)</code> generates positional indices for each token position.</p>
<p>The token embeddings and positional embeddings are added together to produce the initial Transformer input representation.</p>
<p>The model then passes the representation through each Transformer block sequentially:</p>
<pre><code class="language-python">for block in self.transformer_blocks:
    x = block(x)
</code></pre>
<p>This iterative stacking is what allows GPT models to learn increasingly abstract contextual representations.</p>
<p>After normalization, the final hidden states are passed into <code>lm_head</code>, producing <code>logits</code>. These logits are unnormalized prediction scores used to compute probabilities for next-token generation.</p>
<p>The model finally returns the logits tensor, which is typically passed through <code>softmax</code> during inference or used directly with <code>CrossEntropyLoss</code> during training.</p>
<p><strong>GPT-2: Zero-Shot Multitask Architecture</strong></p>
<pre><code class="language-python">class GPT2(nn.Module):
    def __init__(self, vocab_size, d_model, n_layers):
        super().__init__()

        self.token_embedding = nn.Embedding(vocab_size, d_model)
        self.position_embedding = nn.Embedding(1024, d_model)

        self.transformer_blocks = nn.ModuleList([
            TransformerBlock(
                d_model=d_model,
                pre_layer_norm=True
            )
            for _ in range(n_layers)
        ])

        self.final_layer_norm = nn.LayerNorm(d_model)

        self.lm_head = nn.Linear(d_model, vocab_size, bias=False)

    def forward(self, input_ids):
        positions = torch.arange(input_ids.size(1))

        x = (
            self.token_embedding(input_ids)
            + self.position_embedding(positions)
        )

        for block in self.transformer_blocks:
            x = block(x)

        x = self.final_layer_norm(x)

        logits = self.lm_head(x)

        return logits
</code></pre>
<p>Like GPT-1, the model begins with token embeddings and positional embeddings. <code>nn.Embedding</code> converts token IDs into dense vectors, while positional embeddings provide information about token order in the sequence.</p>
<p>One noticeable difference is the larger positional embedding size (<code>1024</code> instead of <code>512</code>), allowing GPT-2 to process longer contexts.</p>
<p>The Transformer layers are stored using <code>nn.ModuleList</code>, but each <code>TransformerBlock</code> now uses:</p>
<pre><code class="language-python">pre_layer_norm=True
</code></pre>
<p>This means layer normalization is applied before attention and feed-forward operations rather than after them. This “Pre-LN” design significantly improves gradient flow and training stability in deeper Transformer models.</p>
<p>The forward pass follows the same overall pipeline:</p>
<ol>
<li><p>Generate positional indices with <code>torch.arange()</code></p>
</li>
<li><p>Add token and positional embeddings</p>
</li>
<li><p>Pass representations through stacked Transformer blocks</p>
</li>
<li><p>Apply final normalization</p>
</li>
<li><p>Project outputs into vocabulary space</p>
</li>
</ol>
<p>The sequential block processing happens here:</p>
<pre><code class="language-python">for block in self.transformer_blocks:
    x = block(x)
</code></pre>
<p>GPT-2 also introduces a small optimization in the output layer:</p>
<pre><code class="language-python">self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
</code></pre>
<pre><code class="language-python">self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
</code></pre>
<p>The bias term is removed because it provides little benefit in large language modeling setups and slightly reduces parameter count.</p>
<p>Finally, the model returns <code>logits</code>, which contain prediction scores for every token in the vocabulary at each sequence position.</p>
<p><strong>GPT-3: Few-Shot / In-Context Learning Architecture</strong></p>
<pre><code class="language-python">class GPT3(nn.Module):
    def __init__(
        self,
        vocab_size=50257,
        d_model=12288,
        n_layers=96,
        n_heads=96,
        context_length=2048
    ):
        super().__init__()

        self.token_embedding = nn.Embedding(vocab_size, d_model)
        self.position_embedding = nn.Embedding(context_length, d_model)

        self.transformer_blocks = nn.ModuleList([
            TransformerBlock(
                d_model=d_model,
                n_heads=n_heads,
                pre_layer_norm=True,
                sparse_attention=True
            )
            for _ in range(n_layers)
        ])

        self.final_layer_norm = nn.LayerNorm(d_model)

        self.lm_head = nn.Linear(
            d_model,
            vocab_size,
            bias=False
        )

    def forward(self, input_ids):
        positions = torch.arange(input_ids.size(1))

        x = (
            self.token_embedding(input_ids)
            + self.position_embedding(positions)
        )

        for block in self.transformer_blocks:
            x = block(x)

        x = self.final_layer_norm(x)

        logits = self.lm_head(x)

        return logits
</code></pre>
<p>Compared to earlier GPT versions, this model dramatically increases scale. The embedding size (<code>d_model=12288</code>) and the number of Transformer layers (<code>96</code>) allow the network to learn highly complex language patterns and long-range dependencies.</p>
<p>The model also uses <code>96</code> attention heads:</p>
<pre><code class="language-python">n_heads=96
</code></pre>
<p>Multi-head attention allows the model to focus on different relationships between tokens simultaneously, improving contextual understanding.</p>
<p>The positional embedding length is expanded to <code>2048</code>, enabling the model to process much longer sequences than GPT-2.</p>
<p>Each Transformer block is configured with:</p>
<pre><code class="language-python">pre_layer_norm=True,
sparse_attention=True
</code></pre>
<p>Pre-layer normalization improves training stability in very deep networks, while sparse attention reduces the computational cost of attention by limiting how many tokens attend to each other. This becomes important at GPT-3 scale, where full attention over long sequences is extremely expensive.</p>
<p>The forward pass follows the standard GPT pipeline:</p>
<ol>
<li><p>Convert token IDs into embeddings</p>
</li>
<li><p>Add positional information</p>
</li>
<li><p>Pass representations through stacked Transformer blocks</p>
</li>
<li><p>Apply final layer normalization</p>
</li>
<li><p>Generate vocabulary logits</p>
</li>
</ol>
<p>The core iterative processing happens here:</p>
<pre><code class="language-plaintext">for block in self.transformer_blocks:
    x = block(x)
</code></pre>
<p>Finally, the output layer projects the hidden states into vocabulary space, producing <code>logits</code> used for next-token prediction during training and text generation.</p>
<h2 id="heading-resources"><strong>Resources:</strong></h2>
<ul>
<li><p><a href="https://github.com/MOHAMMEDFAHD/Pytorch-Collections/tree/main/GPT">Pytorch Projects for GPT series</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/1706.03762?utm_source=chatgpt.com">Attention Is All You Need</a></p>
</li>
<li><p><a href="https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf?utm_source=chatgpt.com">Improving Language Understanding by Generative Pre-Training (GPT-1)</a></p>
</li>
<li><p><a href="https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf?utm_source=chatgpt.com">Language Models are Unsupervised Multitask Learners (GPT-2)</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/1810.04805?utm_source=chatgpt.com">BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/1906.08237?utm_source=chatgpt.com">XLNet: Generalized Autoregressive Pretraining for Language Understanding</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/1907.11692?utm_source=chatgpt.com">RoBERTa: A Robustly Optimized BERT Pretraining Approach</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/1909.08053?utm_source=chatgpt.com">Megatron-LM: Training Multi-Billion Parameter Language Models Using Model Parallelism</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/2009.08366?utm_source=chatgpt.com">Turing-NLG: A 17-Billion-Parameter Language Model by Microsoft</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/1904.10509?utm_source=chatgpt.com">Sparse Transformers</a></p>
</li>
<li><p><a href="https://arxiv.org/abs/2001.08361?utm_source=chatgpt.com">Scaling Laws for Neural Language Models</a></p>
</li>
</ul>
<p><strong>Contact Me</strong></p>
<ul>
<li><p><a href="https://github.com/MOHAMMEDFAHD"><strong>Github</strong></a></p>
</li>
<li><p><a href="https://x.com/programmingoce"><strong>X</strong></a></p>
</li>
<li><p><a href="https://www.linkedin.com/in/mohammed-abrah-6435a63ba/"><strong>Linkedin</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ AI Paper Review: Language Models are Unsupervised Multitask Learners (GPT-2) ]]>
                </title>
                <description>
                    <![CDATA[ Before models like ChatGPT became part of everyday life, AI systems were already getting surprisingly good at generating text. But there was still a major limitation: most models could only perform ta ]]>
                </description>
                <link>https://www.freecodecamp.org/news/ai-paper-review-language-models-are-unsupervised-multitask-learners-gpt-2/</link>
                <guid isPermaLink="false">6a01fbeffca21b0d4b40ae1d</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ nlp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mohammed Fahd Abrah ]]>
                </dc:creator>
                <pubDate>Mon, 11 May 2026 15:55:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/be6d96bd-c687-4fac-a3e2-ea68ba622c51.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Before models like ChatGPT became part of everyday life, AI systems were already getting surprisingly good at generating text. But there was still a major limitation: most models could only perform tasks they were specifically trained for.</p>
<p>If you wanted a model to translate text, summarize an article, or answer questions, you usually had to collect labeled data and train it separately for each task. AI was powerful, but still very narrow.</p>
<p>Then GPT-2 introduced a different idea.</p>
<p>Instead of teaching a model every task individually, researchers explored whether simply training a model to predict the next word on a massive amount of internet text could be enough for useful abilities to emerge on their own.</p>
<p>And surprisingly, it worked.</p>
<p>The model began showing early signs of generalization. It could answer questions, summarize text, translate between languages, and complete prompts – all without task-specific training or fine tuning them toward down stream tasks.</p>
<p>Now, research papers like the one that introduced these new ideas can be difficult and time-consuming to read, especially when they’re filled with technical terminology and experimental details. So in this article, I’ll break the paper down in a simple and practical way.</p>
<p>We’ll look at what problem the paper was trying to solve, the main ideas behind GPT-2, how zero-shot learning works, and why this paper became such an important step toward modern large language models.</p>
<p>By the end, you should understand the key insights of GPT-2 without needing to read the full paper yourself.</p>
<h2 id="heading-paper-overview"><strong>Paper Overview</strong></h2>
<p>In this article, we’ll review the paper <em>Language Models are Unsupervised Multitask Learners</em> by Alec Radford, Jeffrey Wu, Rewon Child, David Luan, Dario Amodei, and Ilya Sutskever.</p>
<p>The paper introduced GPT-2 and showed how a language model trained on massive amounts of text could perform multiple tasks without task-specific training.</p>
<p>Here’s the actual paper if you want to read it yourself:</p>
<p><a href="https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf?utm_source=chatgpt.com">Language Models are Unsupervised Multitask Learners (PDF)</a></p>
<p>And here’s a quick infographic of what we’ll cover in this review:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/0a814405-f634-4251-a1be-b3b02d785691.png" alt="AI paper quick insights" style="display:block;margin:0 auto" width="1414" height="2000" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents:</h2>
<ul>
<li><p><a href="#heading-executive-summary">Executive Summary</a></p>
</li>
<li><p><a href="#heading-goals-of-the-paper">Goals of the Paper</a></p>
</li>
<li><p><a href="#heading-core-idea">Core Idea</a></p>
</li>
<li><p><a href="#heading-methodology">Methodology</a></p>
</li>
<li><p><a href="#heading-zero-shot-setup">Zero-Shot Setup</a></p>
</li>
<li><p><a href="#heading-fine-tuning-vs-zero-shot-learning">Fine-tuning vs Zero-Shot Learning</a></p>
</li>
<li><p><a href="#heading-training-data-web-text">Training Data (Web Text)</a></p>
</li>
<li><p><a href="#heading-input-representation">Input Representation</a></p>
</li>
<li><p><a href="#heading-model-architecture">Model Architecture</a></p>
</li>
<li><p><a href="#heading-experiments">Experiments</a></p>
</li>
<li><p><a href="#heading-key-findings">Key Findings</a></p>
</li>
<li><p><a href="#heading-task-specific">Task-Specific</a></p>
</li>
<li><p><a href="#heading-generalization-vs-memorization">Generalization vs Memorization</a></p>
</li>
<li><p><a href="#heading-discussion">Discussion</a></p>
</li>
<li><p><a href="#heading-limitations">Limitations</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-final-insight">Final Insight</a></p>
</li>
<li><p><a href="#heading-gpt-1-vs-gpt-2-key-differences">GPT-1 vs GPT-2 — Key Differences</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this breakdown, it helps to be familiar with a few basic ideas:</p>
<ul>
<li><p>Reading the previous review, <a href="https://www.freecodecamp.org/news/ai-paper-review-improving-language-understanding-by-generative-pre-training-gpt-1/">AI Paper Review: Improving Language Understanding by Generative Pre-Training (GPT-1)</a>, will be helpful and will give you some solid background info and context (since GPT-2 directly builds on many of the ideas introduced there).</p>
</li>
<li><p>A general understanding of <a href="https://www.freecodecamp.org/news/natural-language-processing-with-spacy-python-full-course/">natural language processing (NLP)</a> and how machines work with text</p>
</li>
<li><p>A high-level idea of what a <a href="https://www.freecodecamp.org/news/how-transformer-models-work-for-language-processing/">Transformer model</a> is (you don’t need deep technical details, just the basic concept)</p>
</li>
<li><p>The difference between supervised learning, unsupervised learning, and zero-shot learning</p>
</li>
<li><p>Basic <a href="https://www.freecodecamp.org/news/learn-the-foundations-of-machine-learning-and-artificial-intelligence/">machine learning concepts</a> like training data, models, and scaling</p>
</li>
</ul>
<p>If you’re not fully comfortable with all of these, that’s completely okay. I’ll keep the explanations as simple and intuitive as possible, focusing more on understanding the ideas than getting lost in heavy technical details.</p>
<h2 id="heading-executive-summary"><strong>Executive Summary</strong></h2>
<p>Before GPT-2, most NLP systems depended heavily on supervised learning. Each task, whether it was translation, question answering, or summarization, typically required its own labeled dataset and a model trained specifically for it.</p>
<p>This paper challenges that approach.</p>
<p>According to the authors, a single large language model, trained only to predict the next word in a sequence of text, can learn to perform many different tasks without any task-specific training.</p>
<p>Instead of being explicitly taught how to solve each problem, the model picks up these abilities from patterns in the data.</p>
<p>In simple terms, the model is not directly trained to translate, answer questions, or summarize. Rather, it learns to do these things implicitly through exposure to large amounts of text.</p>
<p>This marks an important shift. Rather than relying on supervised learning for every task, the paper shows that models can begin to generalize across tasks in what is now known as a zero-shot setting.</p>
<h2 id="heading-goals-of-the-paper"><strong>Goals of the Paper</strong></h2>
<p>To understand the motivation behind this work, it helps to look at the limitations of traditional NLP systems.</p>
<p>According to the authors, most existing approaches rely heavily on labeled datasets, require separate training for each task, and struggle to generalize beyond the specific problems they were designed for.</p>
<p>In practice, this makes systems powerful but narrow: they perform well on what they are trained for, but don’t easily transfer that knowledge elsewhere.</p>
<p>This paper explores a different direction.</p>
<p>The authors ask whether a model can learn to perform multiple tasks without explicit supervision, simply by training on large amounts of text.</p>
<p>They also investigate whether language modeling alone is enough to capture general capabilities, and whether increasing the size of the model and the amount of data can improve this behavior.</p>
<p>At its core, the goal is to move toward more general systems that learn from language itself, rather than from carefully labeled datasets.</p>
<h2 id="heading-core-idea"><strong>Core Idea</strong></h2>
<p>At the heart of the paper is a simple but powerful idea: instead of training models in the traditional supervised way (mapping inputs directly to outputs), the authors train a model to do just one thing: predict the next word in a sequence of text.</p>
<p>At first, this might sound limited. But the key insight is that natural language already contains many examples of tasks embedded within it.</p>
<p>Text on the internet includes questions followed by answers, translations between languages, summaries of longer content, and detailed explanations.</p>
<p>According to the paper, by learning to predict and generate text, the model is indirectly learning how these tasks work. In other words, it begins to model relationships like <em>p(output | input, task)</em> without ever being explicitly told what the task is.</p>
<p>This is what allows the model to move beyond a single objective and start behaving like a general system.</p>
<h2 id="heading-methodology"><strong>Methodology</strong></h2>
<p>To understand how this idea works in practice, it helps to look at how the model is trained.</p>
<p>According to the authors, everything starts with a standard language modeling objective.</p>
<p>The model is trained to predict the next token in a sequence based on the tokens that come before it.</p>
<p>While this may seem simple, it allows the model to learn the underlying structure of language over time.</p>
<p>Formally, this means the model is learning probabilities over sequences of text. In practice, this ability enables it to generate coherent text, complete sentences, and even mimic patterns that resemble specific tasks.</p>
<p>This is what makes the approach powerful. Even though the model is only trained to predict the next word, it ends up capturing much richer behavior that can be applied to a variety of tasks.</p>
<h2 id="heading-zero-shot-setup"><strong>Zero-Shot Setup</strong></h2>
<p>One of the most important differences from earlier approaches is how the model is used after training.</p>
<p>Unlike GPT-1, there's no fine-tuning or task-specific training. The model isn't adapted or retrained for each new task. Instead, everything is handled through the input itself.</p>
<p>According to the authors, tasks are expressed directly as text prompts. For example, you might write something like “Translate to French:” followed by a sentence, or “Answer the question:” followed by a prompt. The model then continues the text in a way that reflects the task.</p>
<p>In practice, this means the model isn't explicitly told what to do through training – it infers the task from the structure of the input and responds accordingly.</p>
<h2 id="heading-fine-tuning-vs-zero-shot-learning"><strong>Fine-tuning vs Zero-Shot Learning</strong></h2>
<table style="min-width:75px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>Aspect</strong></p></td><td><p><strong>Fine-tuning (Task-Specific Training)</strong></p></td><td><p><strong>Zero-Shot Learning</strong></p></td></tr><tr><td><p><strong>Definition</strong></p></td><td><p>Model is trained further on labeled data for a specific task</p></td><td><p>Model performs tasks without any additional training</p></td></tr><tr><td><p><strong>Training Requirement</strong></p></td><td><p>Requires task-specific labeled datasets</p></td><td><p>No labeled data needed for the task</p></td></tr><tr><td><p><strong>Setup</strong></p></td><td><p>Separate training phase for each task</p></td><td><p>Tasks are given as natural language prompts</p></td></tr><tr><td><p><strong>Flexibility</strong></p></td><td><p>Limited to trained tasks</p></td><td><p>Can generalize to many unseen tasks</p></td></tr><tr><td><p><strong>Performance</strong></p></td><td><p>Usually higher on specific tasks</p></td><td><p>Lower, but improving with scale</p></td></tr><tr><td><p><strong>Cost</strong></p></td><td><p>Expensive (training per task)</p></td><td><p>Efficient (no retraining needed)</p></td></tr><tr><td><p><strong>Adaptability</strong></p></td><td><p>Needs retraining for new tasks</p></td><td><p>Adapts instantly via prompts</p></td></tr><tr><td><p><strong>Example (NLP)</strong></p></td><td><p>Train model for sentiment analysis dataset</p></td><td><p>“Classify sentiment: …” prompt</p></td></tr><tr><td><p><strong>Used in</strong></p></td><td><p>GPT-1, traditional NLP systems</p></td><td><p>GPT-2, GPT-3, modern LLMs</p></td></tr><tr><td><p><strong>Main Advantage</strong></p></td><td><p>High accuracy on defined tasks</p></td><td><p>High flexibility and generalization</p></td></tr><tr><td><p><strong>Main Limitation</strong></p></td><td><p>Not scalable across many tasks</p></td><td><p>Less precise than fine-tuned models</p></td></tr></tbody></table>

<h2 id="heading-training-data-web-text"><strong>Training Data (Web Text)</strong></h2>
<p>Another key part of this work is the dataset used to train the model.</p>
<p>Instead of relying on traditional sources like Wikipedia, books, or news articles alone, the authors created a new dataset called <strong>Web Text</strong>.</p>
<p>It consists of millions of documents – around 40 GB of text – collected from links shared on Reddit that received a certain level of engagement.</p>
<p>According to the paper, this filtering step helps improve the overall quality of the data, since the content is more likely to be interesting or useful to readers.</p>
<p>What makes this dataset important is its diversity. It contains real-world language from many domains, and more importantly, it includes natural examples of tasks, such as explanations, question–answer pairs, and translations, embedded within the text itself.</p>
<h2 id="heading-input-representation"><strong>Input Representation</strong></h2>
<p>To process text, the model uses a technique called <strong>Byte Pair Encoding (BPE)</strong>.</p>
<p>According to the authors, BPE works as a middle ground between word-level and character-level representations.</p>
<p>Instead of treating text strictly as full words or individual characters, it breaks it into smaller units that can adapt depending on how frequently patterns appear in the data.</p>
<p>In practice, this allows the model to handle a wide range of text more effectively, including rare words and different languages. It also improves generalization, since the model isn't limited to a fixed vocabulary of complete words.</p>
<h2 id="heading-model-architecture"><strong>Model Architecture</strong></h2>
<p>The model used in this paper is based on a <strong>Transformer (decoder-only)</strong> architecture, similar to GPT-1 but significantly scaled up.</p>
<p>According to the authors, the model relies on <strong>masked self-attention</strong>, which allows it to look at previous tokens in a sequence while predicting the next one.</p>
<p>This means it processes text step by step, always using past context to generate the next token.</p>
<p>Compared to GPT-1, several important changes were introduced.</p>
<p>The model can handle longer context, with sequences of up to 1024 tokens, and uses a larger vocabulary of around 50,000 tokens. It's also much deeper, with more layers and significantly more parameters.</p>
<p>The authors trained multiple versions of the model, ranging from 117 million to 1.5 billion parameters.</p>
<p>The largest of these is what we now refer to as GPT-2, and it's the one responsible for most of the strong results reported in the paper.</p>
<p><strong>Transformer (decoder-only)</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/602d56bd-dbf1-4eec-b11d-6d82b3dcd04d.png" alt="Transformer (decoder-only)" style="display:block;margin:0 auto" width="732" height="1064" loading="lazy">

<p><strong>Note:</strong> The original figure illustrates the complete Transformer architecture (Encoder–Decoder) from <em>Attention Is All You Need</em>. For clarity and relevance to GPT-style models, the image used here was cropped to focus only on the decoder side of the architecture, since GPT models are based on a decoder-only Transformer design.</p>
<p><strong>Reference:</strong> Brownlee, J. <a href="https://machinelearningmastery.com/encoders-and-decoders-in-transformer-models/?utm_source=chatgpt.com">Encoders and Decoders in Transformer Models</a> Machine Learning Mastery.</p>
<h2 id="heading-experiments">Experiments</h2>
<p>To evaluate the model, the authors tested it across a wide range of tasks – but with an important constraint: according to the paper, the model wasn't trained or fine-tuned on any of these tasks.</p>
<p>Instead, everything was evaluated in a zero-shot setting, where the model is simply given a prompt and asked to continue the text.</p>
<p>They applied this setup to different types of problems, including language modeling benchmarks, reading comprehension, translation, summarization, question answering, and commonsense reasoning.</p>
<p>The goal here was not just to measure performance, but to see how far a single model (trained only on raw text) could generalize across tasks without any additional training.</p>
<h2 id="heading-key-findings">Key Findings</h2>
<p>After evaluating the model across different tasks, the results were stronger than many would have expected.</p>
<p>According to the authors, GPT-2 achieves state-of-the-art results on 7 out of 8 language modeling benchmarks in a zero-shot setting.</p>
<p>One of the most important observations is that performance consistently improves as the model size increases, following a roughly log-linear trend.</p>
<p>In other words, scaling up the model leads to better results across tasks.</p>
<p>The paper also shows that larger models display more consistent multitask behavior.</p>
<p>For example, GPT-2 performs well on tasks that require long-range understanding, such as LAMBADA, and shows competitive results in reading comprehension on datasets like CoQA.</p>
<p>It even demonstrates early capabilities in translation and can answer factual questions without being explicitly trained for those tasks.</p>
<p>In practice, the key takeaway is clear: increasing model size and data plays a major role in unlocking these capabilities.</p>
<h2 id="heading-task-specific">Task-Specific</h2>
<p>Looking more closely at individual tasks, the paper gives a clearer picture of where the model performs well and where it still struggles.</p>
<p>GPT-2 shows surprisingly strong results in reading comprehension, even without any task-specific training. But its performance on summarization is still limited.</p>
<p>While it can generate summaries that look reasonable, they're often less accurate compared to supervised approaches.</p>
<p>For translation, the model demonstrates some ability, but the results are still far from competitive.</p>
<p>On the other hand, question answering improves noticeably as the model size increases, suggesting that scale plays an important role in this capability.</p>
<p>Overall, the model is far from perfect. But what stands out is that it's clearly beginning to learn general skills across tasks, even without being explicitly trained for them.</p>
<h2 id="heading-generalization-vs-memorization">Generalization vs Memorization</h2>
<p>A natural question that comes up is whether the model is actually learning useful patterns or simply memorizing the training data.</p>
<p>The authors address this directly. They analyze overlap between the training dataset and evaluation benchmarks using n-gram comparisons, looking for signs that the model might be copying rather than generalizing.</p>
<p>According to the paper, while some overlap does exist (as is common in large datasets), it's not enough to explain the model’s performance.</p>
<p>They also observe that the model still underfits the data, meaning it hasn’t fully captured everything in the training set.</p>
<p>This is an important point: if the model was mainly memorizing, we would expect it to fit the data much more closely.</p>
<p>In practice, this suggests that the improvements are coming from genuine learning rather than simple memorization, even though some overlap is unavoidable.</p>
<h2 id="heading-discussion">Discussion</h2>
<p>This section is where the authors step back and reflect on what these results actually mean.</p>
<p>According to the paper, language models trained on large and diverse datasets aren't just learning representations of text. They're beginning to learn how to perform tasks directly, even without supervision.</p>
<p>In other words, pre-training is doing more than providing useful features: it's capturing patterns that resemble real task behavior.</p>
<p>At the same time, the authors are careful not to overstate the results.</p>
<p>While the zero-shot capabilities are impressive, performance is still far from practical on many tasks.</p>
<p>Some outputs look convincing on the surface but lack accuracy when measured more carefully.</p>
<p>In practice, this section highlights both sides of the story. The approach is clearly promising, but it's still an early step toward more general systems.</p>
<h2 id="heading-limitations">Limitations</h2>
<p>Despite the progress shown in the paper, the approach still has several important limitations.</p>
<p>According to the authors, zero-shot performance, while impressive, is generally weaker than fully supervised models on many tasks.</p>
<p>The results also depend heavily on scale, both in terms of model size and the amount of data used. This means that smaller models don't show the same level of capability.</p>
<p>In addition, some tasks, such as summarization, remain relatively weak.</p>
<p>The model can produce outputs that look plausible, but they often lack accuracy or consistency when evaluated more carefully.</p>
<p>Another practical challenge is the cost. Training these models requires significant computational resources and large datasets, which makes this approach difficult to reproduce or scale for many researchers.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The paper ends with a simple but powerful idea.</p>
<p>According to the authors, when a language model is trained on a sufficiently large and diverse dataset – and with enough capacity – it begins to generalize across tasks and perform them without explicit training.</p>
<p>This suggests that the model isn't just learning language, but also the structure of the tasks embedded within it.</p>
<p>In practice, this points to a different way of thinking about AI systems. Instead of designing and training a model for each specific task, we can focus on training a single model on large-scale language data&nbsp;– and allow useful capabilities to emerge naturally from that process.</p>
<h2 id="heading-final-insight">Final Insight</h2>
<p>If GPT-1 introduced the idea of combining pre-training with fine-tuning, GPT-2 takes that idea a step further.</p>
<p>According to the paper, pre-training alone - when done at a large enough scale – can already produce models that begin to perform a wide range of tasks without any additional training.</p>
<p>This is a subtle but important shift, because it suggests that general capabilities can emerge directly from exposure to large amounts of text.</p>
<p>In my view, this is the point where things start to change direction.</p>
<p>The focus moves away from designing task-specific systems and toward building more general models that can adapt on their own.</p>
<p>This idea directly sets the stage for what comes next: models like GPT-3, ChatGPT, and modern large language systems that build on this same principle.</p>
<h2 id="heading-gpt-1-vs-gpt-2-key-differences"><strong>GPT-1 vs GPT-2 — Key Differences</strong></h2>
<table style="min-width:75px"><colgroup><col style="min-width:25px"><col style="min-width:25px"><col style="min-width:25px"></colgroup><tbody><tr><td><p><strong>Aspect</strong></p></td><td><p><strong>GPT-1</strong></p></td><td><p><strong>GPT-2</strong></p></td></tr><tr><td><p><strong>Core Idea</strong></p></td><td><p>Pre-training + fine-tuning</p></td><td><p>Pre-training alone (zero-shot)</p></td></tr><tr><td><p><strong>Training Approach</strong></p></td><td><p>Two-stages: learn language, then adapt to tasks</p></td><td><p>Single stage: learn language and infer tasks</p></td></tr><tr><td><p><strong>Supervision</strong></p></td><td><p>Requires labeled data for fine-tuning</p></td><td><p>No labeled data needed for tasks</p></td></tr><tr><td><p><strong>Task Handling</strong></p></td><td><p>Tasks require separate fine-tuning</p></td><td><p>Tasks handled via prompts (zero-shot)</p></td></tr><tr><td><p><strong>Generalization</strong></p></td><td><p>Limited, depends on fine-tuning</p></td><td><p>Stronger generalization across tasks</p></td></tr><tr><td><p><strong>Model Role</strong></p></td><td><p>Learns language, then adapts</p></td><td><p>Learns language and tasks together</p></td></tr><tr><td><p><strong>Architecture</strong></p></td><td><p>Transformer (decoder-based)</p></td><td><p>Transformer (decoder-only, scaled up)</p></td></tr><tr><td><p><strong>Model Size</strong></p></td><td><p>Smaller (~117M parameters)</p></td><td><p>Much larger (up to 1.5B parameters)</p></td></tr><tr><td><p><strong>Context Length</strong></p></td><td><p>Shorter context</p></td><td><p>Longer context (up to 1024 tokens)</p></td></tr><tr><td><p><strong>Dataset</strong></p></td><td><p>Books Corpus + other curated datasets</p></td><td><p>Web Text (large, diverse internet data)</p></td></tr><tr><td><p><strong>Key Capability</strong></p></td><td><p>Transfer learning</p></td><td><p>Zero-shot learning</p></td></tr><tr><td><p><strong>Performance Style</strong></p></td><td><p>Strong after fine-tuning</p></td><td><p>Strong without any task training</p></td></tr><tr><td><p><strong>Limitations</strong></p></td><td><p>Depends on labeled data</p></td><td><p>Depends heavily on scale (data + compute)</p></td></tr><tr><td><p><strong>Main Contribution</strong></p></td><td><p>Introduced pre-training paradigm</p></td><td><p>Showed emergence of multitask behavior</p></td></tr><tr><td><p><strong>Impact</strong></p></td><td><p>Foundation of modern NLP pipelines</p></td><td><p>Shift toward general-purpose models</p></td></tr></tbody></table>

<h2 id="heading-resources">Resources:</h2>
<ul>
<li><p><a href="https://github.com/MOHAMMEDFAHD/Pytorch-Collections/tree/main/GPT">Pytorch Projects for GPT series</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/1706.03762">Attention Is All You Need</a></p>
</li>
<li><p><a href="https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf">Improving Language Understanding by Generative Pre-Training</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/1810.04805">BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding</a></p>
</li>
<li><p><a href="https://papers.nips.cc/paper_files/paper/2015/file/7137debd45ae4d0ab9aa953017286b20-Paper.pdf">Semi-supervised Sequence Learning</a></p>
</li>
<li><p><a href="https://aclanthology.org/P18-1031.pdf?">Universal Language Model Fine-tuning for Text Classification</a></p>
</li>
<li><p><a href="https://aclanthology.org/N18-1202.pdf">Deep Contextualized Word Representations</a></p>
</li>
<li><p><a href="https://arxiv.org/pdf/1508.07909">Neural Machine Translation of Rare Words with Subword Units</a></p>
</li>
<li><p><a href="https://papers.nips.cc/paper_files/paper/2013/file/9aa42b31882ec039965f3c4923ce901b-Paper.pdf">Distributed Representations of Words and Phrases and Their Compositionality</a></p>
</li>
<li><p><a href="https://aclanthology.org/D14-1162.pdf">GloVe: Global Vectors for Word Representation</a></p>
</li>
</ul>
<h3 id="heading-contact-me"><strong>Contact Me</strong></h3>
<ul>
<li><p><a href="https://github.com/MOHAMMEDFAHD"><strong>Github</strong></a></p>
</li>
<li><p><a href="https://x.com/programmingoce"><strong>X</strong></a></p>
</li>
<li><p><a href="https://www.linkedin.com/in/mohammed-abrah-6435a63ba/"><strong>Linkedin</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Rise of AI Agents: How Software Is Learning to Act ]]>
                </title>
                <description>
                    <![CDATA[ Software has always been reactive. You click a button, it responds. You call an API, it returns data. Even the most sophisticated systems have historically depended on explicit instructions and tightl ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-rise-of-ai-agents-how-software-is-learning-to-act/</link>
                <guid isPermaLink="false">69fe184ef239332df4ea34e7</guid>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 17:07:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1351f6d0-79c2-491b-a8e7-943cc9ece905.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Software has always been reactive.</p>
<p>You click a button, it responds. You call an API, it returns data.</p>
<p>Even the most sophisticated systems have historically depended on explicit instructions and tightly defined workflows. That model is starting to break.</p>
<p>A new class of software is emerging that doesn't just respond, but act.</p>
<p>This shift isn't cosmetic. It changes how software is designed, how systems are operated, and how work itself is executed.</p>
<p>Instead of encoding every step of a workflow, developers are now defining goals, constraints, and tools, then letting software figure out the execution path. The result is software that behaves less like a function and more like an operator.</p>
<p>In this article, you'll learn what AI agents actually are, how they differ from traditional software systems, and why they're starting to represent a major shift in modern software design.</p>
<p>This article is written for developers, technical founders, engineering managers, and anyone building software systems with AI components.</p>
<p>You don't need prior experience building AI agents, but it helps to be familiar with Basic Python syntax and Large language models (LLMs)</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-from-deterministic-systems-to-goal-driven-execution">From Deterministic Systems to Goal-Driven Execution</a></p>
</li>
<li><p><a href="#heading-the-core-components-of-an-ai-agent">The Core Components of an AI Agent</a></p>
</li>
<li><p><a href="#heading-why-ai-agents-are-emerging-now">Why AI Agents Are Emerging Now</a></p>
</li>
<li><p><a href="#heading-the-illusion-and-reality-of-autonomy">The Illusion and Reality of Autonomy</a></p>
</li>
<li><p><a href="#heading-designing-agents-that-work-in-practice">Designing Agents That Work in Practice</a></p>
</li>
<li><p><a href="#heading-multi-agent-systems-and-coordination">Multi-Agent Systems and Coordination</a></p>
</li>
<li><p><a href="#heading-where-ai-agents-are-already-delivering-value">Where AI Agents Are Already Delivering Value</a></p>
</li>
<li><p><a href="#heading-the-shift-in-software-design">The Shift in Software Design</a></p>
</li>
<li><p><a href="#heading-what-comes-next">What Comes Next</a></p>
</li>
</ul>
<h2 id="heading-from-deterministic-systems-to-goal-driven-execution">From Deterministic Systems to Goal-Driven Execution</h2>
<p>Traditional software systems are deterministic. Given the same input, they produce the same output.</p>
<p>This predictability is what makes them reliable, but it's also what limits them. Any variation in workflow requires new code, new conditions, and new branches.</p>
<p>AI agents introduce a different model. They're goal-driven rather than instruction-driven. Instead of specifying every step, you define an objective and provide access to tools. The agent decides how to achieve the objective, often adapting in real time.</p>
<p>Consider a simple task like summarizing a set of documents and emailing the result. In a traditional system, you would write a pipeline that loads documents, processes them, formats the output, and sends an email. Each step is explicitly coded.</p>
<p>With an agent, the system might look more like this:</p>
<pre><code class="language-plaintext">from openai import OpenAI

client = OpenAI()
goal = "Summarize all documents in /reports and email a concise briefing to the leadership team"
tools = [
    "read_files",
    "summarize_text",
    "send_email"
]
response = client.responses.create(
    model="gpt-4.1",
    input=f"Goal: {goal}. Available tools: {tools}"
)
print(response.output_text)
</code></pre>
<p>This example is simplified, but it captures the shift. The developer defines intent and capability. The agent determines execution.</p>
<h2 id="heading-the-core-components-of-an-ai-agent">The Core Components of an AI&nbsp;Agent</h2>
<p>To understand how agents work, it helps to break them into components. At a high level, most agents consist of reasoning, memory, and tools.</p>
<p>Reasoning is handled by a large language model. This is what allows the agent to interpret goals, plan actions, and adapt when something fails. It's not just generating text, it's generating decisions.</p>
<p>Memory allows the agent to maintain context across steps. Without memory, the agent behaves like a stateless function. With memory, it can track progress, recall past actions, and refine its approach.</p>
<p><a href="https://www.freecodecamp.org/news/how-to-build-your-first-mcp-server-using-fastmcp/">Tools are what make the agent useful</a>. A tool can be anything from an API to a database query to a shell command. The agent doesn't need to know how the tool works internally. It only needs to know when and how to use it.</p>
<p>Here is a minimal example of tool usage in an agent loop:</p>
<pre><code class="language-plaintext">def agent_loop(goal, tools):
    context = []
    
    while True:
        prompt = f"Goal: {goal}\nContext: {context}\nWhat should be done next?"
        
        decision = model.generate(prompt)
        
        if decision == "DONE":
            break
        
        if decision.startswith("USE_TOOL"):
            tool_name, tool_input = parse_tool_call(decision)
            result = tools[tool_name](tool_input)
            context.append(result)
        else:
            context.append(decision)
    
    return context
</code></pre>
<p>This loop is where the agent “acts.” It observes, decides, executes, and updates its understanding.</p>
<h2 id="heading-why-ai-agents-are-emerging-now">Why AI Agents Are Emerging&nbsp;Now</h2>
<p>The idea of autonomous software isn't new. What has changed is the capability of the underlying models.</p>
<p>Large language models can now reason across multiple steps, interpret unstructured inputs, and generate structured outputs that can drive real systems.</p>
<p>Equally important is the ecosystem around them. APIs are more standardized, infrastructure is more programmable, and data is more accessible. This makes it easier to expose tools and let them interact with real systems helping build some of the <a href="https://nexos.ai/blog/best-ai-agents/">best AI agents</a> in use today.</p>
<p>There's also an economic driver. Many workflows today are still manual, even in highly digitized organizations. These workflows often involve coordination across systems, interpretation of data, and decision-making under uncertainty. This is exactly the kind of work agents are suited for.</p>
<h2 id="heading-the-illusion-and-reality-of-autonomy">The Illusion and Reality of&nbsp;Autonomy</h2>
<p>It's tempting to describe AI agents as fully autonomous. In practice, most are not. They operate within constraints defined by developers. They rely on tools that expose only certain actions. They're often monitored, rate-limited, and evaluated at each step.</p>
<p>What makes them different isn't complete autonomy, but partial autonomy. They can decide how to execute within a bounded environment.</p>
<p>This distinction matters because it affects how systems are designed. You're not building a system that always behaves predictably. You're building a system that explores a solution space and converges on an outcome.</p>
<p>That introduces new challenges. Agents can take inefficient paths. They can misinterpret goals. They can fail in ways that are hard to debug because the failure isn't a single error, but a chain of decisions.</p>
<h2 id="heading-designing-agents-that-work-in-practice">Designing Agents That Work in&nbsp;Practice</h2>
<p>Building an agent is easy. Building one that works reliably is harder. The difference comes down to control.</p>
<p>One approach is to constrain the agent’s <a href="https://milvus.io/ai-quick-reference/what-is-an-action-space-in-rl">action space</a>. Instead of giving it open-ended access, you define a limited set of tools with clear interfaces. This reduces ambiguity and makes behavior more predictable.</p>
<p>Another approach is to introduce intermediate checkpoints. Instead of letting the agent run freely, you validate its decisions at key steps. You can do this through rules, secondary models, or even human review.</p>
<p>Here's an example of adding a validation layer:</p>
<pre><code class="language-plaintext">def safe_execute(tool, input_data):
    if not validate_input(tool, input_data):
        return "Invalid input"
    
    result = tool(input_data)
    
    if not validate_output(tool, result):
        return "Invalid output"
    
    return result
</code></pre>
<p>This pattern is critical in production systems. It turns an unconstrained agent into a controlled system that can still adapt, but within safe boundaries.</p>
<h2 id="heading-multi-agent-systems-and-coordination">Multi-Agent Systems and Coordination</h2>
<p>As agents become more capable, a single agent is often not enough. Complex tasks can be decomposed into multiple agents, each responsible for a specific function.</p>
<p>For example, one agent might handle data retrieval, another might handle analysis, and a third might handle communication. These agents can coordinate by passing structured messages.</p>
<pre><code class="language-plaintext">class Message:
    def __init__(self, sender, receiver, content):
        self.sender = sender
        self.receiver = receiver
        self.content = content

def send_message(agent, message):
    return agent.process(message)
message = Message("retriever", "analyst", "Data collected from API")
response = send_message(analyst_agent, message)
</code></pre>
<p>This model starts to resemble a distributed system, but with agents instead of services. Coordination becomes a first-class concern. You need to define protocols, handle failures, and ensure consistency across agents.</p>
<h2 id="heading-where-ai-agents-are-already-delivering-value">Where AI Agents Are Already Delivering Value</h2>
<p>Despite the hype, there are concrete areas where agents are already useful. Internal tooling is one of them. Automating repetitive workflows, generating reports, and orchestrating tasks across systems are all well-suited for agents.</p>
<p>Customer support is another area. Agents can handle complex queries that require accessing multiple systems, not just retrieving canned responses.</p>
<p>Security and compliance workflows are also a strong fit. These often involve monitoring signals, correlating data, and taking action based on rules that aren't always deterministic.</p>
<p>What these use cases have in common is that they involve structured environments with clear objectives and measurable outcomes. Agents perform best when the problem space is bounded, even if the execution path is not.</p>
<h2 id="heading-the-shift-in-software-design">The Shift in Software&nbsp;Design</h2>
<p>The rise of AI agents isn't just about adding a new feature. It's about changing the abstraction layer of software.</p>
<p>Instead of writing code that directly implements behavior, you're designing systems that enable behavior. You define goals, expose capabilities, and enforce constraints. The actual execution becomes dynamic.</p>
<p>This requires a different mindset. Debugging is no longer just about tracing code. It's about understanding decision paths. Testing is no longer just about input-output pairs. It's about evaluating behavior across scenarios.</p>
<p>Observability becomes critical. You need to log not just what the system did, but why it did it. This includes prompts, intermediate decisions, and tool interactions.</p>
<h2 id="heading-what-comes-next">What Comes&nbsp;Next</h2>
<p>AI agents are still in the relatively early stages. The current generation is powerful but imperfect. Reliability is a major challenge. So is cost, especially when agents require multiple model calls per task.</p>
<p>But the direction is clear: software is moving from static execution to dynamic action. The boundary between user and system is becoming less rigid. Instead of telling software what to do step by step, users will increasingly define outcomes and let systems figure out the rest.</p>
<p>This doesn't eliminate the need for engineers. It changes what engineers do. The focus shifts from implementing logic to designing systems that can reason, act, and adapt.</p>
<p>The rise of AI agents marks a transition. Software is no longer just a tool. It's becoming an actor.</p>
<p><em>Join my</em> <a href="https://applyaito.substack.com/"><em><strong>Applied AI newsletter</strong></em></a> <em>to learn how to build and ship real AI systems. Practical projects, production-ready code, and direct Q&amp;A. You can also</em> <a href="https://www.linkedin.com/in/manishmshiva/"><em><strong>connect with me on</strong></em> <em><strong>LinkedIn</strong></em></a><em><strong>.</strong></em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Run Open Source LLMs Locally and in the Cloud ]]>
                </title>
                <description>
                    <![CDATA[ Learn how to work with a wide range of open large language models (LLMs) such as Gemma, Kimmy, and GLM across various local and cloud-based environments. We just posted a course on the freeCodeCamp.or ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-run-open-source-llms-locally-and-in-the-cloud/</link>
                <guid isPermaLink="false">69fc99c59f93a850a4d15442</guid>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Thu, 07 May 2026 13:55:17 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/5e964c21-8ba1-4c64-8dee-be757e52cac6.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Learn how to work with a wide range of open large language models (LLMs) such as Gemma, Kimmy, and GLM across various local and cloud-based environments.</p>
<p>We just posted a course on the <a href="http://freeCodeCamp.org">freeCodeCamp.org</a> YouTube channel, taught by Andrew Brown, that explores how to use coding harnesses like Claude Code and Pi Agent to build real-world agentic workflows while benchmarking model performance and hardware requirements.</p>
<p>The course provides a practical look at the current state of open AI by conducting "smoke tests," such as building Flappy Bird clones to evaluate how different models handle real-world coding tasks. You will explore the hardware requirements necessary for local execution, including the VRAM limitations that often make cloud-hosted options more viable for large context windows.</p>
<p>Andrew also evaluates various coding harnesses, like Claude Code and PI Coding Agent. By the end of the course, you will understand which models, such as Kimmy 2.5 and Gemma 4, are most reliable for tool calling and structured code generation.</p>
<p>Watch the full course for free on <a href="https://youtu.be/HNVaYYxmwLU">the freeCodeCamp.org YouTube channel</a>.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/HNVaYYxmwLU" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Your Own Language-Specific LLM [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ What if you could build your own LLM, one that speaks your native language, all from scratch? That's exactly what we'll do in this tutorial. The best way to understand how LLMs work is by actually bui ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-your-own-language-specific-llm-handbook/</link>
                <guid isPermaLink="false">69ebd996b463d4844c5084e4</guid>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gen ai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Wisamul Haque ]]>
                </dc:creator>
                <pubDate>Fri, 24 Apr 2026 20:59:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bbdca07e-40a3-4b6e-955f-9573f895154a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>What if you could build your own LLM, one that speaks your native language, all from scratch? That's exactly what we'll do in this tutorial. The best way to understand how LLMs work is by actually building one.</p>
<p>We'll go through each step of creating your own LLM in a specific language (Urdu in this case). This will help you understand what goes on inside an LLM.</p>
<p>Modern LLMs trace back to the research paper that changed everything: <a href="https://arxiv.org/abs/1706.03762"><strong>"Attention Is All You Need"</strong></a>. But rather than getting lost in the math (I am bad at math, sadly), we'll learn by building one from scratch.</p>
<h3 id="heading-who-is-this-handbook-for">Who is This Handbook For?</h3>
<p>Software engineers, product owners, or anyone curious about how LLMs work. If you have a little machine learning knowledge, that would be great, but if not, no worries. I've written this so that you don't have to go anywhere outside this tutorial.</p>
<p>By the end, you will have a <strong>working Urdu LLM chatbot</strong> deployed and running. You can create one for your own native language as well by following the steps defined below.</p>
<h3 id="heading-a-note-on-expectations">A Note on Expectations:</h3>
<p>The goal here is to educate ourselves on how LLMs work by practically going through all the steps.</p>
<p>The goal is <strong>not</strong> that your LLM will act like ChatGPT. That has multiple constraints like massive datasets, months of training, and reinforcement learning from human feedback (RLHF), all of which you'll understand better by going through this tutorial.</p>
<h3 id="heading-a-note-on-the-code">A Note on the Code:</h3>
<p>The code in this tutorial was largely generated using Claude Opus 4. This is worth highlighting because it shows that LLMs are not just coding assistants that help you ship features faster. They can also be powerful learning tools.</p>
<p>By prompting Claude to generate, explain, and iterate on each component, I was able to understand the internals of LLM training far more deeply than reading documentation alone.</p>
<p>If you're following along, I encourage you to do the same: use an LLM for your learning.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-components-of-llm-training">Components of LLM Training</a></p>
<ul>
<li><a href="#heading-tech-stack-required">Tech Stack Required</a></li>
</ul>
</li>
<li><p><a href="#heading-1-data-preparation">1. Data Preparation</a></p>
<ul>
<li><a href="#heading-data-cleaning">Data Cleaning</a></li>
</ul>
</li>
<li><p><a href="#heading-2-tokenization">2. Tokenization</a></p>
<ul>
<li><p><a href="#heading-tokenization-approaches">Tokenization Approaches</a></p>
</li>
<li><p><a href="#heading-special-tokens">Special Tokens</a></p>
</li>
<li><p><a href="#heading-bpe-tokenizer-configuration">BPE Tokenizer Configuration</a></p>
</li>
<li><p><a href="#heading-building-the-tokenizer">Building the Tokenizer</a></p>
</li>
<li><p><a href="#heading-training-the-tokenizer">Training the Tokenizer</a></p>
</li>
<li><p><a href="#heading-configuring-post-processing-auto-wrapping-with-boseos">Configuring Post-Processing (Auto-Wrapping with BOS/EOS)</a></p>
</li>
<li><p><a href="#heading-testing-the-tokenizer">Testing the Tokenizer</a></p>
</li>
<li><p><a href="#heading-fertility-score">Fertility Score</a></p>
</li>
<li><p><a href="#heading-saving-the-tokenizer">Saving the Tokenizer</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-3-pre-training">3. Pre-Training</a></p>
<ul>
<li><p><a href="#heading-steps-to-do-pre-training">Steps to Do Pre-Training</a></p>
</li>
<li><p><a href="#heading-model-configuration">Model Configuration</a></p>
</li>
<li><p><a href="#heading-configuration-parameters-explained">Configuration Parameters Explained</a></p>
</li>
<li><p><a href="#heading-transformer-architecture">Transformer Architecture</a></p>
</li>
<li><p><a href="#heading-transformer-code-breakdown">Transformer Code Breakdown</a></p>
</li>
<li><p><a href="#heading-loading-the-dataset-and-training">Loading the Dataset and Training</a></p>
</li>
<li><p><a href="#heading-training-code-explained-line-by-line">Training Code Explained: Line by Line</a></p>
</li>
<li><p><a href="#heading-summary-one-batch-in-6-steps">Summary: One Batch in 6 Steps</a></p>
</li>
<li><p><a href="#heading-key-metrics">Key Metrics</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-4-supervised-fine-tuning-sft">4. Supervised Fine-Tuning (SFT)</a></p>
<ul>
<li><p><a href="#heading-formatting-conversations-for-training">Formatting Conversations for Training</a></p>
</li>
<li><p><a href="#heading-formatting-summary">Formatting Summary</a></p>
</li>
<li><p><a href="#heading-sft-dataset-amp-dataloader">SFT Dataset &amp; DataLoader</a></p>
</li>
<li><p><a href="#heading-loading-the-pre-trained-model">Loading the Pre-trained Model</a></p>
</li>
<li><p><a href="#heading-sft-training-loop">SFT Training Loop</a></p>
</li>
<li><p><a href="#heading-chat-function-inference">Chat Function: Inference</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-5-deployment">5. Deployment</a></p>
<ul>
<li><p><a href="#heading-gradio-web-interface-apppy">Gradio Web Interface (app.py)</a></p>
</li>
<li><p><a href="#heading-deployment-options">Deployment Options</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-full-pipeline-summary">Full Pipeline Summary</a></p>
</li>
<li><p><a href="#heading-results">Results</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-components-of-llm-training">Components of LLM Training</h2>
<p>In this tutorial, we'll be covering the following components one by one with code examples for better understanding:</p>
<ol>
<li><p>Data Preparation</p>
</li>
<li><p>Tokenization</p>
</li>
<li><p>Pre-Training</p>
</li>
<li><p>Supervised Fine-Tuning (SFT)</p>
</li>
<li><p>Deployment</p>
</li>
</ol>
<h3 id="heading-tech-stack-required">Tech Stack Required</h3>
<p>Before starting the steps, here is the tech stack you need:</p>
<ol>
<li><p>Python 3.9+</p>
</li>
<li><p>PyTorch</p>
</li>
<li><p>Tokenizers / SentencePiece</p>
</li>
<li><p>Hugging Face Datasets &amp; Hub</p>
</li>
<li><p>regex, BeautifulSoup4, requests (for data cleaning)</p>
</li>
<li><p>tqdm, matplotlib (for training utilities)</p>
</li>
<li><p>Gradio (for chat UI deployment)</p>
</li>
<li><p>Google Colab (free T4 GPU for training)</p>
</li>
</ol>
<p><strong>Note:</strong> Make sure to install all the dependencies listed in the <code>requirements.txt</code> file of the repository before getting started.</p>
<h2 id="heading-1-data-preparation">1. Data Preparation</h2>
<p>In data preparation, the first and foremost step is <strong>data collection</strong>. An LLM needs to be trained on a large amount of text data. There is no single place to get this data. Depending on the type of model you want to build, you can collect text from many sources:</p>
<ul>
<li><p><strong>Digital libraries and archives:</strong> <a href="https://archive.org/">Internet Archive</a> or Wikipedia dumps</p>
</li>
<li><p><strong>Code repositories:</strong> GitHub, GitLab (useful if your model needs to understand code)</p>
</li>
<li><p><strong>Web scraping:</strong> Crawling websites, blogs, and forums using automated scripts</p>
</li>
<li><p><strong>Academic datasets:</strong> Research papers, open-access journals</p>
</li>
<li><p><strong>Pre-built datasets:</strong> Platforms like <a href="https://huggingface.co/datasets">Hugging Face Datasets</a> and <a href="https://www.kaggle.com/datasets">Kaggle</a> host thousands of ready-to-use datasets</p>
</li>
</ul>
<p>In practice, large-scale LLMs like GPT and LLaMA rely heavily on web scraping from many sources using automated pipelines. But there's one important rule to follow: <strong>only use publicly available, open-source data.</strong> Don't scrape private or personal user information. Stick to data that's explicitly shared for public use or falls under permissive licenses.</p>
<p><strong>Also,</strong> keep this principle in mind: <strong>garbage in, garbage out</strong>. Just getting the data isn't enough. It should be correct, clean, and without noise.</p>
<p>In actual practice, you can collect data from different sources. In my case, I found good enough data from <strong>Hugging Face</strong>. Hugging Face has <a href="https://huggingface.co/datasets/uonlp/CulturaX"><strong>CulturaX</strong></a> that has multilingual datasets. The dataset was huge, so I didn't download all of it and only downloaded a small portion.</p>
<p>For this tutorial, I used <strong>Hugging Face</strong> as my data source. I chose it for a few reasons.</p>
<p>First, since the goal was to learn how LLMs work, I wanted to spend my time on the model, not on writing web scrapers. Hugging Face already has a large collection of datasets in a cleaned and structured format, which saves a lot of upfront work.</p>
<p>Second, Hugging Face offers language-specific datasets. Since I was building an Urdu LLM, I needed Urdu text specifically, and Hugging Face has <a href="https://huggingface.co/datasets/uonlp/CulturaX"><strong>CulturaX</strong></a> which provides multilingual datasets including Urdu and many other languages. The dataset was huge, so I avoided downloading all of it and only downloaded a small portion.</p>
<p><strong>Important:</strong> Before you start downloading the dataset from Hugging Face, you need to create an account. Then log into the CLI, from where you'll be able to download the dataset.</p>
<p>In the script below, we load the dataset from Hugging Face and turn streaming to <code>True</code>. The purpose of doing this is so that we don't have to download all the data but only chunks of samples as defined in <code>NUM_SAMPLES</code>.</p>
<pre><code class="language-python"># ============================================================
# Option A: Download from CulturaX (recommended, high quality)
# ============================================================
# CulturaX is a cleaned version of mC4 + OSCAR
# We stream it to avoid downloading the entire dataset

NUM_SAMPLES = 100_000  # Start with 100K samples (~50-100MB text)

print("Loading CulturaX Urdu dataset (streaming)...")
dataset = load_dataset(
    "uonlp/CulturaX",
    "ur",                    # Urdu language code
    split="train",
    streaming=True,          # Don't download everything
    trust_remote_code=True
)

# Collect samples
raw_texts = []
for i, sample in enumerate(tqdm(dataset, total=NUM_SAMPLES, desc="Downloading")):
    if i &gt;= NUM_SAMPLES:
        break
    raw_texts.append(sample["text"])

print(f"\nDownloaded {len(raw_texts)} samples")
print(f"Total characters: {sum(len(t) for t in raw_texts):,}")
print(f"\nSample text (first 500 chars):")
print(raw_texts[0][:500])
</code></pre>
<h3 id="heading-data-cleaning">Data Cleaning</h3>
<p>Simply having the data is not enough to start training your model. The next step is probably the most important one: <strong>data cleaning</strong>. The goal is to make the data as pure as possible.</p>
<p>As I was building a language-specific Urdu LLM, I had to write cleaning logic to remove non-Urdu text, HTML links, special characters, duplicate content, and excess whitespace. All these factors pollute the training data and can cause issues during training.</p>
<p>Based on the type of dataset, some language-specific or use-case cleaning will be required.</p>
<p>One thing that might be new to you is the <strong>NFKC Unicode normalization</strong> step. This normalizes text that appears the same but exists in different Unicode forms, keeping one canonical form.</p>
<p>You'll also see some regex patterns that are used to keep only the Urdu text. As Urdu script is based on Arabic, we'll use Arabic Unicode ranges. I also removed artifacts like <code>//</code>, <code>--</code>, and extra empty spaces that were present in the raw data.</p>
<p>This cleaning took multiple iterations. I reviewed the results manually each time and identified issues like inconsistent spacing, long dashes, and stray punctuation. All of these can negatively impact the next stages, so it's important to clean thoroughly.</p>
<p>This also gives you an idea of how important the data part still is and how much LLMs depend on data.</p>
<p>Here is the cleaning function I used:</p>
<pre><code class="language-python">def clean_urdu_text(text: str) -&gt; str:
    """
    Clean a single Urdu text document.
    
    Steps:
    1. Remove URLs
    2. Remove HTML tags and entities
    3. Remove email addresses
    4. Normalize Unicode (NFKC normalization)
    5. Remove non-Urdu characters (keep Urdu + punctuation + digits)
    6. Normalize repeated punctuation (۔۔۔, ..., - -, etc.)
    7. Normalize whitespace
    """
    import unicodedata
    
    # Step 1: Remove URLs
    text = re.sub(r'https?://\S+|www\.\S+', '', text)
    
    # Step 2: Remove HTML tags
    text = re.sub(r'&lt;[^&gt;]+&gt;', '', text)
    # Remove HTML entities
    text = re.sub(r'&amp;[a-zA-Z]+;', ' ', text)
    text = re.sub(r'&amp;#\d+;', ' ', text)
    
    # Step 3: Remove email addresses
    text = re.sub(r'\S+@\S+', '', text)
    
    # Step 4: Unicode normalization (NFKC)
    # This normalizes different representations of the same character
    text = unicodedata.normalize('NFKC', text)
    
    # Step 5: Keep only Urdu characters, basic punctuation, digits, and whitespace
    # Urdu Unicode ranges + Arabic punctuation + Western digits + basic punctuation
    urdu_pattern = regex.compile(
        r'[^'
        r'\u0600-\u06FF'    # Arabic (includes Urdu)
        r'\u0750-\u077F'    # Arabic Supplement
        r'\u08A0-\u08FF'    # Arabic Extended-A
        r'\uFB50-\uFDFF'    # Arabic Presentation Forms-A
        r'\uFE70-\uFEFF'    # Arabic Presentation Forms-B
        r'0-9۰-۹'           # Western and Eastern Arabic-Indic digits
        r'\s'               # Whitespace
        r'۔،؟!٪'           # Urdu punctuation (full stop, comma, question mark, etc.)
        r'.,:;!?\-\(\)"\']'  # Basic Latin punctuation
    )
    text = urdu_pattern.sub(' ', text)
    
    # Step 6: Normalize repeated punctuation
    text = re.sub(r'۔{2,}', '۔', text)
    text = re.sub(r'\.{2,}', '.', text)
    text = re.sub(r'-\s*-+', '-', text)
    text = re.sub(r'-{2,}', '-', text)
    text = re.sub(r'،{2,}', '،', text)
    text = re.sub(r',{2,}', ',', text)
    text = re.sub(r'\s+[۔\.\-,،]\s+', ' ', text)
    
    # Step 7: Normalize whitespace
    text = re.sub(r'\n{3,}', '\n\n', text)  # Max 2 newlines
    text = re.sub(r'[^\S\n]+', ' ', text)    # Collapse spaces (but keep newlines)
    text = text.strip()
    
    return text


def is_mostly_urdu(text: str, threshold: float = 0.5) -&gt; bool:
    """
    Check if text is mostly Urdu characters.
    This filters out documents that are primarily English/other languages.
    
    threshold: minimum fraction of characters that must be Urdu
    """
    if len(text) == 0:
        return False
    urdu_chars = len(regex.findall(r'[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]', text))
    return (urdu_chars / len(text)) &gt; threshold


# Test the cleaning function
sample = raw_texts[0]
print("=== BEFORE CLEANING ===")
print(sample[:300])
print("\n=== AFTER CLEANING ===")
cleaned = clean_urdu_text(sample)
print(cleaned[:300])
print(f"\nIs mostly Urdu: {is_mostly_urdu(cleaned)}")
</code></pre>
<p>After cleaning, I stored the data in two formats: a <strong>text file</strong> (used for tokenizer training) and a <strong>JSONL file</strong> (used for pre-training). Each format serves a specific purpose in the upcoming steps.</p>
<h2 id="heading-2-tokenization">2. Tokenization</h2>
<p>The next step after cleaning is <strong>tokenization</strong>. Tokenization converts text into numbers, and provides a way to convert those numbers back into text.</p>
<p>This is necessary because neural networks can't understand text – they only understand numbers. So tokenization is essentially a translation layer between human language and what the model can process.</p>
<p>For example:</p>
<pre><code class="language-plaintext">"hello world"  →  ["hel", "lo", " world"]  →  [1245, 532, 995]
"اردو زبان"   ←  ["ار", "دو", "زب", "ان"]  ←  [412, 87, 953, 201]
</code></pre>
<h3 id="heading-tokenization-approaches">Tokenization Approaches</h3>
<p>There are three main approaches to tokenization:</p>
<h4 id="heading-approach-1-character-level">Approach 1: Character-level</h4>
<p>With this approach, you split text into individual characters:</p>
<ul>
<li><p><code>hello</code> -&gt; <code>['h', 'e', 'l', 'l', 'o']</code></p>
</li>
<li><p><code>اردو</code> -&gt; <code>['ا', 'ر', 'د', 'و']</code></p>
</li>
</ul>
<p>The problem is that sequences become very long. A 1000-word document might be 5000+ tokens. The model has to learn to combine characters into words, which is very hard.</p>
<h4 id="heading-approach-2-word-level">Approach 2: Word-level</h4>
<p>In this approach, you split based on spaces between words:</p>
<ul>
<li><p><code>hello how are you</code> -&gt; <code>['hello', 'how', 'are', 'you']</code></p>
</li>
<li><p><code>اردو بہت اچھی زبان ہے</code> -&gt; <code>['اردو', 'بہت', 'اچھی', 'زبان', 'ہے']</code></p>
</li>
</ul>
<p>This problem is that a language's vocabulary is huge (Urdu has 100K+ unique words, English has 170K+). The model can't handle new or rare words (the out-of-vocabulary problem).</p>
<h4 id="heading-approach-3-subword-using-bpe-byte-pair-encoding">Approach 3: Subword using BPE (Byte Pair Encoding)</h4>
<p>With this approach, the model learns common character sequences from data.</p>
<ul>
<li><p><code>unhappiness</code> might split as <code>['un', 'happi', 'ness']</code></p>
</li>
<li><p><code>مکمل</code> might split as <code>['مکم', 'ل']</code> or stay whole if common enough.</p>
</li>
</ul>
<p>This is a smaller vocabulary (we use 32K tokens), and it can handle any word, even new ones. Common words stay as single tokens.</p>
<p>BPE is the industry standard, used by GPT, LLaMA, and most modern LLMs. Here is how it works step by step:</p>
<ol>
<li><p><strong>Start with characters</strong>: vocabulary = all individual characters</p>
</li>
<li><p><strong>Count pairs</strong>: find the most frequent adjacent pair of tokens</p>
</li>
<li><p><strong>Merge</strong>: combine that pair into a new token</p>
</li>
<li><p><strong>Repeat</strong>: until vocabulary reaches desired size</p>
</li>
</ol>
<p>Here's an example:</p>
<pre><code class="language-plaintext">Start:  ا ر د و   ز ب ا ن
Merge 1: 'ا ر' -&gt; 'ار'    (most common pair)
Result: ار د و   ز ب ا ن
Merge 2: 'ز ب' -&gt; 'زب'    (next most common)
Result: ار د و   زب ا ن
...and so on for 32,000 merges
</code></pre>
<p>This is the approach we'll use for our Urdu LLM. I trained a BPE tokenizer with a vocabulary size of 32K tokens on the cleaned Urdu corpus.</p>
<h3 id="heading-special-tokens">Special Tokens</h3>
<p>Along with BPE, we also need to add some <strong>special tokens</strong>. These tokens give the model structural information it needs during training and inference.</p>
<table>
<thead>
<tr>
<th>Token</th>
<th>Purpose</th>
<th>Why It Is Needed</th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;pad&gt;</code></td>
<td>Padding for equal-length sequences</td>
<td>Batching requires all sequences to be the same length. Shorter sequences are filled with <code>&lt;pad&gt;</code> tokens.</td>
</tr>
<tr>
<td><code>&lt;unk&gt;</code></td>
<td>Unknown word fallback</td>
<td>If the model encounters a token not in the vocabulary, it maps to <code>&lt;unk&gt;</code> instead of failing.</td>
</tr>
<tr>
<td><code>&lt;bos&gt;</code></td>
<td>Marks the start of a sequence</td>
<td>Tells the model where the input begins, leading to more stable generation.</td>
</tr>
<tr>
<td><code>&lt;eos&gt;</code></td>
<td>Marks the end of a sequence</td>
<td>Tells the model when to stop generating. Without it, output may run forever or stop randomly.</td>
</tr>
<tr>
<td><code>&lt;sep&gt;</code></td>
<td>Separates segments</td>
<td>In chat format, separates the system prompt, user message, and assistant response so the model knows which role is which.</td>
</tr>
<tr>
<td>`&lt;</td>
<td>user</td>
<td>&gt;`</td>
</tr>
<tr>
<td>`&lt;</td>
<td>assistant</td>
<td>&gt;`</td>
</tr>
<tr>
<td>`&lt;</td>
<td>system</td>
<td>&gt;`</td>
</tr>
</tbody></table>
<h3 id="heading-bpe-tokenizer-configuration">BPE Tokenizer Configuration</h3>
<p>I set vocab size to <strong>32K</strong>. What does that mean? It means the model will have 32K tokens in its vocabulary lookup table.</p>
<p>This is a good balance between language coverage and model size. If we increase vocab size, the embedding layer and output layer both grow, which means more parameters to train. For a learning project, 32K keeps things manageable.</p>
<p><code>MIN_FREQUENCY</code> is set to 2, meaning a token must appear at least twice in the corpus to be included. This filters out one-off noise tokens that would waste vocabulary slots.</p>
<p><strong>For reference:</strong> GPT-2 uses a vocabulary of 50K tokens, and LLaMA uses 32K. Our choice of 32K is in line with production models.</p>
<pre><code class="language-python">VOCAB_SIZE = 32_000  # Number of tokens in our vocabulary
MIN_FREQUENCY = 2    # Token must appear at least twice (filters noise)

# Special tokens - these have reserved IDs
SPECIAL_TOKENS = [
    "&lt;pad&gt;",    # ID 0: padding
    "&lt;unk&gt;",    # ID 1: unknown
    "&lt;bos&gt;",    # ID 2: beginning of sequence 
    "&lt;eos&gt;",    # ID 3: end of sequence
    "&lt;sep&gt;",    # ID 4: separator (for chat format)
    "&lt;|user|&gt;",     # ID 5: user turn marker (for chat)
    "&lt;|assistant|&gt;", # ID 6: assistant turn marker (for chat)
    "&lt;|system|&gt;",    # ID 7: system prompt marker (for chat)
]
</code></pre>
<h3 id="heading-building-the-tokenizer">Building the Tokenizer</h3>
<p>Next up is creating the tokenizer using the cleaned text file we created earlier. First, we'll import the required libraries and set up the file paths:</p>
<pre><code class="language-python">import os
from pathlib import Path
from tokenizers import (
    Tokenizer,
    models,
    trainers,
    pre_tokenizers,
    decoders,
    processors,
    normalizers,
)

PROJECT_ROOT = Path(".").resolve().parent
CLEANED_DIR = PROJECT_ROOT / "data" / "cleaned"
TOKENIZER_DIR = PROJECT_ROOT / "tokenizer" / "urdu_tokenizer"
TOKENIZER_DIR.mkdir(parents=True, exist_ok=True)

CORPUS_FILE = str(CLEANED_DIR / "urdu_corpus.txt")
print(f"Corpus file: {CORPUS_FILE}")
print(f"Tokenizer output: {TOKENIZER_DIR}")

# Verify corpus exists
assert os.path.exists(CORPUS_FILE), f"Corpus not found at {CORPUS_FILE}. Run notebook 01 first!"
file_size_mb = os.path.getsize(CORPUS_FILE) / 1024 / 1024
print(f"Corpus size: {file_size_mb:.1f} MB")
</code></pre>
<p>Now we'll configure the tokenizer components:</p>
<pre><code class="language-python"># ============================================================
# Build the tokenizer
# ============================================================

# Step 1: Create a BPE model (the core algorithm)
tokenizer = Tokenizer(models.BPE(unk_token="&lt;unk&gt;"))

# Step 2: Add normalizer (text cleaning before tokenization)
# NFKC normalizes Unicode (e.g., different forms of the same Arabic letter)
tokenizer.normalizer = normalizers.NFKC()

# Step 3: Pre-tokenizer (how to split text before BPE)
# We use Metaspace which replaces spaces with ▁ and splits on them
# This preserves space information so we can reconstruct the original text
tokenizer.pre_tokenizer = pre_tokenizers.Metaspace()

# Step 4: Decoder (how to convert tokens back to text)
# Metaspace decoder converts ▁ back to spaces
tokenizer.decoder = decoders.Metaspace()

# Step 5: Configure the trainer
trainer = trainers.BpeTrainer(
    vocab_size=VOCAB_SIZE,
    min_frequency=MIN_FREQUENCY,
    special_tokens=SPECIAL_TOKENS,
    show_progress=True,
    initial_alphabet=[]  # Learn alphabet from data
)

print("Tokenizer configured. Ready to train!")
</code></pre>
<h3 id="heading-training-the-tokenizer">Training the Tokenizer</h3>
<p>Once the tokenizer is configured, the next step is to run it. This will take roughly 5 to 10 minutes depending on your device.</p>
<pre><code class="language-python">print("Training tokenizer... (this may take a few minutes)")
tokenizer.train([CORPUS_FILE], trainer)

print(f"\n Tokenizer trained!")
print(f"  Vocabulary size: {tokenizer.get_vocab_size():,}")
</code></pre>
<h3 id="heading-configuring-post-processing-auto-wrapping-with-boseos">Configuring Post-Processing (Auto-Wrapping with BOS/EOS)</h3>
<p>Next, we'll configure post-processing so the tokenizer automatically wraps every sequence with <code>&lt;bos&gt;</code> and <code>&lt;eos&gt;</code> tokens. This means we don't have to manually add them each time we encode text:</p>
<pre><code class="language-python">bos_id = tokenizer.token_to_id("&lt;bos&gt;")
eos_id = tokenizer.token_to_id("&lt;eos&gt;")

tokenizer.post_processor = processors.TemplateProcessing(
    single=f"&lt;bos&gt;:0 $A:0 &lt;eos&gt;:0",
    pair=f"&lt;bos&gt;:0 \(A:0 &lt;sep&gt;:0 \)B:1 &lt;eos&gt;:1",
    special_tokens=[
        ("&lt;bos&gt;", bos_id),
        ("&lt;eos&gt;", eos_id),
        ("&lt;sep&gt;", tokenizer.token_to_id("&lt;sep&gt;")),
    ],
)

print("Post-processor configured (auto-adds &lt;bos&gt; and &lt;eos&gt;)")
</code></pre>
<p><strong>Note:</strong> You might wonder why we need this step when we already defined <code>&lt;bos&gt;</code> and <code>&lt;eos&gt;</code> in <code>SPECIAL_TOKENS</code>. The <code>SPECIAL_TOKENS</code> list only <strong>reserves vocabulary slots</strong> for these tokens (assigns them IDs). Post-processing tells the tokenizer to <strong>automatically insert</strong> them into every encoded sequence.</p>
<p>Without this step, the tokens would exist in the vocabulary but never appear in your data unless you added them manually each time.</p>
<h3 id="heading-testing-the-tokenizer">Testing the Tokenizer</h3>
<p>The final step in tokenization is to test it. The test encodes Urdu sentences into token IDs, then decodes those IDs back into text. If the decoded text matches the original input, the tokenizer is working correctly. This roundtrip test confirms that no information is lost during encoding and decoding:</p>
<pre><code class="language-python">test_sentences = [
    "اردو ایک بہت خوبصورت زبان ہے",           # "Urdu is a very beautiful language"
    "پاکستان کا دارالحکومت اسلام آباد ہے",      # "The capital of Pakistan is Islamabad"
    "آج موسم بہت اچھا ہے",                     # "The weather is very nice today"
    "مصنوعی ذہانت مستقبل کی ٹیکنالوجی ہے",     # "AI is the technology of the future"
    "السلام علیکم! آپ کیسے ہیں؟",               # "Peace be upon you! How are you?"
]

print("=" * 70)
print("TOKENIZER TEST RESULTS")
print("=" * 70)

for sentence in test_sentences:
    encoded = tokenizer.encode(sentence)
    decoded = tokenizer.decode(encoded.ids)
    
    print(f"\n Input:    {sentence}")
    print(f" Token IDs: {encoded.ids}")
    print(f"  Tokens:   {encoded.tokens}")
    print(f" Decoded:  {decoded}")
    print(f"   Num tokens: {len(encoded.ids)}")
    print(f"   Roundtrip OK: {sentence in decoded}")
    print("-" * 70)
</code></pre>
<p>Here is what the output looks like:</p>
<pre><code class="language-plaintext">======================================================================
TOKENIZER TEST RESULTS
======================================================================

 Input:    اردو ایک بہت خوبصورت زبان ہے
 Token IDs: [2, 1418, 324, 431, 2965, 1430, 276, 3]
 Tokens:   ['&lt;bos&gt;', '▁اردو', '▁ایک', '▁بہت', '▁خوبصورت', '▁زبان', '▁ہے', '&lt;eos&gt;']
 Decoded:  اردو ایک بہت خوبصورت زبان ہے
   Num tokens: 8
   Roundtrip OK: True
----------------------------------------------------------------------

 Input:    پاکستان کا دارالحکومت اسلام آباد ہے
 Token IDs: [2, 474, 289, 3699, 616, 1004, 276, 3]
 Tokens:   ['&lt;bos&gt;', '▁پاکستان', '▁کا', '▁دارالحکومت', '▁اسلام', '▁آباد', '▁ہے', '&lt;eos&gt;']
 Decoded:  پاکستان کا دارالحکومت اسلام آباد ہے
   Num tokens: 8
   Roundtrip OK: True
</code></pre>
<p>Notice how <code>&lt;bos&gt;</code> and <code>&lt;eos&gt;</code> are automatically added (thanks to our post-processing step), common Urdu words like <code>پاکستان</code> stay as single tokens, and the <code>▁</code> prefix marks word boundaries from the Metaspace pre-tokenizer. Most importantly, every roundtrip succeeds, meaning decoded text matches the original input exactly.</p>
<h3 id="heading-fertility-score">Fertility Score</h3>
<p>Fertility is the average number of tokens per word.</p>
<ul>
<li><p>A fertility of 1 means each word maps to one token (ideal but unrealistic in modern subword tokenizers).</p>
</li>
<li><p>In modern LLMs, fertility is usually around 1.3–2.5 depending on the language.</p>
</li>
<li><p>Higher fertility means more token splitting, which increases cost and reduces efficiency, but it's also influenced by language complexity, not just tokenizer quality.</p>
</li>
</ul>
<pre><code class="language-python"># ============================================================
# Calculate fertility score on training corpus
# ============================================================
import json

jsonl_file = CLEANED_DIR / "urdu_corpus.jsonl"
corpus_words = 0
corpus_tokens = 0
sample_size = 10000  # Sample 10K documents for speed

print(f"Calculating fertility on {sample_size:,} documents from corpus...")

with open(jsonl_file, "r", encoding="utf-8") as f:
    for i, line in enumerate(f):
        if i &gt;= sample_size:
            break
        doc = json.loads(line)
        text = doc["text"]
        
        words = text.split()
        tokens = tokenizer.encode(text).tokens
        n_tokens = len(tokens) - 2  # Remove &lt;bos&gt; and &lt;eos&gt;
        
        corpus_words += len(words)
        corpus_tokens += n_tokens

corpus_fertility = corpus_tokens / corpus_words
print(f"\n📊 Fertility Score (corpus): {corpus_fertility:.2f} tokens/word")
print(f"   (Total: {corpus_words:,} words → {corpus_tokens:,} tokens)")
print(f"   Documents sampled: {min(i+1, sample_size):,}")

if corpus_fertility &lt; 2.0:
    print("   ✅ Excellent! Tokenizer is well-optimized for Urdu.")
elif corpus_fertility &lt; 3.0:
    print("   ⚠️ Good, but could be better. Consider larger vocab.")
else:
    print("   ❌ High fertility. The tokenizer needs improvement.")
</code></pre>
<p>The fertility score we get here is 1.04, which is quite good. But keep in mind that this number is artificially low because the tokenizer was trained on the same small corpus it's being evaluated on. With a larger or unseen dataset, fertility would likely be higher (closer to the 1.3-2.5 range typical for production tokenizers).</p>
<h3 id="heading-saving-the-tokenizer">Saving the Tokenizer</h3>
<p>The final step is to save the tokenizer in JSON format and verify that it loads correctly:</p>
<pre><code class="language-python"># ============================================================
# Save the tokenizer
# ============================================================

tokenizer_path = str(TOKENIZER_DIR / "urdu_bpe_tokenizer.json")
tokenizer.save(tokenizer_path)

print(f" Tokenizer saved to: {tokenizer_path}")
print(f"   File size: {os.path.getsize(tokenizer_path) / 1024:.0f} KB")

# Verify we can load it back
loaded_tokenizer = Tokenizer.from_file(tokenizer_path)
test = loaded_tokenizer.encode("اردو ایک خوبصورت زبان ہے")
print(f"\n   Verification: {test.tokens}")
print(f"    Tokenizer loads correctly!")
</code></pre>
<p>Once saved, we have a lookup table. Using this, along with our corpus of data, we can perform the next important step: <strong>pre-training</strong>.</p>
<h2 id="heading-3-pre-training">3. Pre-Training</h2>
<p>In this part, the model learns the language, grammar, patterns, and vocabulary. Once training is done, the model is able to predict the next word in a sequence, and this is where we start to see raw data turning into an LLM.</p>
<p><strong>LLMs are actually next-word predictors.</strong> Given a sequence of words, they predict the most probable next word.</p>
<p>With the help of training, the model learns:</p>
<ul>
<li><p>The syntax of the language</p>
</li>
<li><p>Semantics, the contextual meaning</p>
</li>
<li><p>Frequently used expressions</p>
</li>
<li><p>Facts from the training dataset</p>
</li>
</ul>
<p>For training, you have some options. As the model is small, you can train it on your local machine. It will be slower but will get the job done.</p>
<p>The other option is using Google Colab. This is the one I used – the free version was enough for the training I required, using a T4 GPU.</p>
<h3 id="heading-steps-to-do-pre-training">Steps to Do Pre-Training</h3>
<ol>
<li><p>Upload the dataset JSONL file and tokenizer to Google Drive.</p>
</li>
<li><p>Set the model configuration (vocab size, layers, heads, and so on).</p>
</li>
<li><p>Define the transformer architecture (attention, feed-forward, blocks).</p>
</li>
<li><p>Load and tokenize the corpus into training/validation splits.</p>
</li>
<li><p>Run the training loop with optimizer, LR schedule, and checkpointing.</p>
</li>
</ol>
<h3 id="heading-model-configuration">Model Configuration</h3>
<pre><code class="language-python">from dataclasses import dataclass

@dataclass
class UrduLLMConfig:
    # Vocabulary
    vocab_size: int = 32_000
    pad_token_id: int = 0
    bos_token_id: int = 2
    eos_token_id: int = 3

    # Model Architecture
    d_model: int = 384
    n_layers: int = 6
    n_heads: int = 6
    d_ff: int = 1536  # 4 * d_model
    dropout: float = 0.1
    max_seq_len: int = 256

    # Training
    batch_size: int = 32
    learning_rate: float = 3e-4
    weight_decay: float = 0.1
    max_epochs: int = 10
    warmup_steps: int = 500
    grad_clip: float = 1.0
</code></pre>
<h4 id="heading-configuration-parameters-explained">Configuration parameters explained:</h4>
<p>The vocabulary parameters (<code>vocab_size</code>, <code>pad_token_id</code>, <code>bos_token_id</code>, <code>eos_token_id</code>) simply match the tokenizer we built earlier. <code>vocab_size</code> is 32K (our BPE vocabulary), and the special token IDs (0, 2, 3) correspond to the positions we assigned during tokenizer training.</p>
<h4 id="heading-model-architecture-parameters">Model architecture parameters:</h4>
<table>
<thead>
<tr>
<th>Variable</th>
<th>What it Means</th>
<th>Example</th>
<th>Impact of Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>d_model</code></td>
<td>Embedding/vector size per token</td>
<td>384</td>
<td>Higher: better understanding but slower &amp; more memory. Lowe: faster but less expressive</td>
</tr>
<tr>
<td><code>n_layers</code></td>
<td>Number of transformer layers</td>
<td>6</td>
<td>More layers: deeper understanding but higher latency. Fewer: faster but less powerful</td>
</tr>
<tr>
<td><code>n_heads</code></td>
<td>Attention heads per layer</td>
<td>6</td>
<td>More heads: better context capture. Too few: limited attention diversity</td>
</tr>
<tr>
<td><code>d_ff</code></td>
<td>Feedforward layer size</td>
<td>1536</td>
<td>Larger: more computation power. Smaller: faster but weaker transformations</td>
</tr>
<tr>
<td><code>dropout</code></td>
<td>% of neurons dropped during training</td>
<td>0.1</td>
<td>Higher: prevents overfitting but may underfit. Lower: better training fit but risk of overfitting</td>
</tr>
<tr>
<td><code>max_seq_len</code></td>
<td>Maximum tokens per input</td>
<td>256</td>
<td>Higher: more context but slower &amp; costly. Lower: faster but limited context</td>
</tr>
</tbody></table>
<h4 id="heading-training-hyperparameters">Training hyperparameters:</h4>
<table>
<thead>
<tr>
<th>Variable</th>
<th>What it Means</th>
<th>Example</th>
<th>Impact of Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>batch_size</code></td>
<td>Samples per training step</td>
<td>32</td>
<td>Larger: faster training but needs more memory. Smaller: stable but slower</td>
</tr>
<tr>
<td><code>learning_rate</code></td>
<td>Step size for updates</td>
<td>0.0003</td>
<td>Too high: unstable training. Too low: very slow learning</td>
</tr>
<tr>
<td><code>weight_decay</code></td>
<td>Regularization strength</td>
<td>0.1</td>
<td>Higher: reduces overfitting. Lower: risk of overfitting</td>
</tr>
<tr>
<td><code>max_epochs</code></td>
<td>Full dataset passes</td>
<td>10</td>
<td>More: better learning but risk of overfitting. Fewer: undertrained model</td>
</tr>
<tr>
<td><code>warmup_steps</code></td>
<td>Gradual LR increase steps</td>
<td>500</td>
<td>More: smoother start, safer training. Less: risk of early instability</td>
</tr>
<tr>
<td><code>grad_clip</code></td>
<td>Max gradient value</td>
<td>1.0</td>
<td>Lower: stable but slower learning. Higher: risk of exploding gradients</td>
</tr>
</tbody></table>
<h3 id="heading-transformer-architecture">Transformer Architecture</h3>
<p>Next up is the main part of training: writing the <strong>transformer architecture</strong>. Before jumping into code, it's important to know what a transformer architecture is.</p>
<p>To learn in depth about what transformers are and how they differ from RNNs and CNNs, I would recommend going through this article: <a href="https://aws.amazon.com/what-is/transformers-in-artificial-intelligence/">AWS: What is Transformers in Artificial Intelligence</a></p>
<p>But in short:</p>
<blockquote>
<p><em>"Transformers are a type of neural network architecture that transforms or changes an input sequence into an output sequence."</em></p>
</blockquote>
<p>The original Transformer paper introduced both an <strong>encoder</strong> (reads input) and a <strong>decoder</strong> (generates output). But GPT-style models like ours use only the decoder part. This is called a <strong>decoder-only</strong> architecture.</p>
<p>The decoder takes a sequence of tokens, applies <a href="https://www.ibm.com/think/topics/self-attention"><strong>self-attention</strong></a> to understand relationships between them, and predicts the next token.</p>
<p>Self-attention is what makes transformers powerful: instead of processing tokens one by one in order (like RNNs), the model looks at all previous tokens simultaneously and determines which ones are most relevant for the current prediction.</p>
<p>Here's the complete transformer code. A detailed breakdown of each component follows:</p>
<pre><code class="language-python">import math
import torch
import torch.nn as nn
import torch.nn.functional as F


class MultiHeadSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.n_heads = config.n_heads
        self.d_model = config.d_model
        self.head_dim = config.d_model // config.n_heads

        self.qkv_proj = nn.Linear(config.d_model, 3 * config.d_model)
        self.out_proj = nn.Linear(config.d_model, config.d_model)
        self.dropout = nn.Dropout(config.dropout)

    def forward(self, x, mask=None):
        B, T, C = x.shape

        qkv = self.qkv_proj(x)
        qkv = qkv.reshape(B, T, 3, self.n_heads, self.head_dim)
        qkv = qkv.permute(2, 0, 3, 1, 4)
        q, k, v = qkv[0], qkv[1], qkv[2]

        attn = (q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5)

        if mask is not None:
            attn = attn.masked_fill(mask == 0, float('-inf'))

        attn = F.softmax(attn, dim=-1)
        attn = self.dropout(attn)

        out = attn @ v
        out = out.transpose(1, 2).reshape(B, T, C)
        out = self.out_proj(out)
        return out


class FeedForward(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.fc1 = nn.Linear(config.d_model, config.d_ff)
        self.fc2 = nn.Linear(config.d_ff, config.d_model)
        self.dropout = nn.Dropout(config.dropout)

    def forward(self, x):
        x = F.gelu(self.fc1(x))
        x = self.dropout(x)
        x = self.fc2(x)
        return x


class TransformerBlock(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.ln1 = nn.LayerNorm(config.d_model)
        self.attn = MultiHeadSelfAttention(config)
        self.ln2 = nn.LayerNorm(config.d_model)
        self.ff = FeedForward(config)
        self.dropout = nn.Dropout(config.dropout)

    def forward(self, x, mask=None):
        x = x + self.dropout(self.attn(self.ln1(x), mask))
        x = x + self.dropout(self.ff(self.ln2(x)))
        return x


class UrduGPT(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.config = config

        self.token_emb = nn.Embedding(config.vocab_size, config.d_model)
        self.pos_emb = nn.Embedding(config.max_seq_len, config.d_model)
        self.dropout = nn.Dropout(config.dropout)

        self.blocks = nn.ModuleList([
            TransformerBlock(config) for _ in range(config.n_layers)
        ])

        self.ln_f = nn.LayerNorm(config.d_model)
        self.head = nn.Linear(config.d_model, config.vocab_size, bias=False)

        # Weight tying
        self.head.weight = self.token_emb.weight

        self.apply(self._init_weights)

    def _init_weights(self, module):
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)

    def forward(self, input_ids, targets=None):
        B, T = input_ids.shape
        device = input_ids.device

        tok_emb = self.token_emb(input_ids)
        pos = torch.arange(0, T, dtype=torch.long, device=device)
        pos_emb = self.pos_emb(pos)

        x = self.dropout(tok_emb + pos_emb)

        # Causal mask
        mask = torch.tril(torch.ones(T, T, device=device)).unsqueeze(0).unsqueeze(0)

        for block in self.blocks:
            x = block(x, mask)

        x = self.ln_f(x)
        logits = self.head(x)

        loss = None
        if targets is not None:
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))

        return {'logits': logits, 'loss': loss}

    @torch.no_grad()
    def generate(self, input_ids, max_new_tokens=100, temperature=0.8,
                 top_k=50, top_p=0.9, eos_token_id=None):
        """
        Generate text autoregressively.

        Sampling strategies:
        - temperature: Controls randomness (low = deterministic, high = creative)
        - top_k: Only consider the top K most likely tokens
        - top_p (nucleus): Only consider tokens whose cumulative probability &lt;= p
        - eos_token_id: Stop generating when this token is produced
        """
        self.eval()
        eos_token_id = eos_token_id or getattr(self.config, 'eos_token_id', None)

        for _ in range(max_new_tokens):
            idx_cond = input_ids if input_ids.size(1) &lt;= self.config.max_seq_len \
                       else input_ids[:, -self.config.max_seq_len:]

            outputs = self.forward(idx_cond)
            logits = outputs["logits"][:, -1, :] / temperature

            # Top-K filtering
            if top_k &gt; 0:
                v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
                logits[logits &lt; v[:, [-1]]] = float('-inf')

            # Top-P (nucleus) filtering
            if top_p &lt; 1.0:
                sorted_logits, sorted_indices = torch.sort(logits, descending=True)
                cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
                sorted_indices_to_remove = cumulative_probs &gt; top_p
                sorted_indices_to_remove[:, 1:] = sorted_indices_to_remove[:, :-1].clone()
                sorted_indices_to_remove[:, 0] = 0
                indices_to_remove = sorted_indices_to_remove.scatter(
                    1, sorted_indices, sorted_indices_to_remove
                )
                logits[indices_to_remove] = float('-inf')

            probs = F.softmax(logits, dim=-1)
            next_token = torch.multinomial(probs, num_samples=1)
            input_ids = torch.cat([input_ids, next_token], dim=1)

            if eos_token_id is not None and next_token.item() == eos_token_id:
                break

        return input_ids
</code></pre>
<p>This code builds a text prediction machine. You give it some Urdu words, and it guesses the next word, over and over, until it forms a sentence. That's literally how ChatGPT works too, just much bigger.</p>
<h3 id="heading-transformer-code-breakdown">Transformer Code Breakdown</h3>
<h4 id="heading-1-multiheadselfattention-the-lookback-system">1. MultiHeadSelfAttention: "The Lookback System"</h4>
<p>Imagine reading a sentence. When you see the word "اس" (this), your brain looks back to figure out what "this" refers to. That's attention.</p>
<p><strong>Q, K, V</strong>: Think of it like a library:</p>
<ul>
<li><p><strong>Query (Q):</strong> "I'm looking for information about X"</p>
</li>
<li><p><strong>Key (K):</strong> Each previous word holds up a sign: "I have info about Y"</p>
</li>
<li><p><strong>Value (V):</strong> The actual information that word carries</p>
</li>
</ul>
<p><strong>6 heads</strong> = 6 different "readers" looking at the sentence simultaneously. One might focus on grammar, another on meaning, another on nearby words, and so on.</p>
<p><strong>Causal mask</strong> = A rule that says: "You can only look at words that came before you, not after." (Because when generating, future words don't exist yet!)</p>
<p><strong>The math:</strong> Multiply Q×K to get "how relevant is each word?", then use those scores to grab the most useful info from V.</p>
<h4 id="heading-2-feedforward-the-thinking-step">2. FeedForward: "The Thinking Step"</h4>
<p>After attention figured out which words matter, this is where the model actually thinks about what they mean.</p>
<p>It's just two layers:</p>
<ul>
<li><p><strong>Expand (384 → 1536):</strong> Give the model more "brain space" to think</p>
</li>
<li><p><strong>Shrink (1536 → 384):</strong> Compress the thought back down</p>
</li>
<li><p><strong>GELU activation:</strong> A filter that decides "keep this thought" or "discard it" (smoothly, not harshly)</p>
</li>
</ul>
<h4 id="heading-3-transformerblock-one-round-of-reading">3. TransformerBlock: "One Round of Reading"</h4>
<p>One pass of reading a sentence and thinking about it.</p>
<ul>
<li><p><strong>Step 1:</strong> Look at other words (attention)</p>
</li>
<li><p><strong>Step 2:</strong> Think about what you saw (feed-forward)</p>
</li>
<li><p><strong>LayerNorm:</strong> Like resetting your brain between steps so numbers don't get too big or too small.</p>
</li>
<li><p><strong>Residual connection (</strong><code>x + ...</code><strong>):</strong> The model keeps its original thought AND adds the new insight. It's like taking notes: you don't erase old notes, you add new ones.</p>
</li>
</ul>
<p>The model does this 6 times (6 blocks). Each round understands the text a little deeper.</p>
<h4 id="heading-4-urdugpt-the-full-machine">4. UrduGPT: "The Full Machine"</h4>
<p><strong>Setup (</strong><code>__init__</code><strong>):</strong></p>
<ul>
<li><p><strong>Token embedding:</strong> A giant lookup table. Each of 32,000 Urdu words/subwords gets a list of 384 numbers that represent its "meaning."</p>
</li>
<li><p><strong>Position embedding:</strong> Another lookup table that tells the model "this word is 1st, this is 2nd, this is 3rd..." (otherwise it wouldn't know word order).</p>
</li>
<li><p><strong>6 Transformer blocks:</strong> The 6 rounds of reading described above.</p>
</li>
<li><p><strong>LM head:</strong> At the end, converts the model's internal "thoughts" (384 numbers) back into a score for each of the 32,000 possible next words.</p>
</li>
<li><p><strong>Weight tying:</strong> The input lookup table and output scoring table share the same data. Saves memory and actually works better!</p>
</li>
</ul>
<p><strong>Processing (</strong><code>forward</code><strong>):</strong></p>
<ol>
<li><p>Look up each word's meaning (embedding)</p>
</li>
<li><p>Add position info</p>
</li>
<li><p>Run through 6 rounds of attention + thinking</p>
</li>
<li><p>Score every possible next word</p>
</li>
<li><p>If we know the correct answer, calculate how wrong we were (loss)</p>
</li>
</ol>
<p><strong>Generating text (</strong><code>generate</code><strong>):</strong> A simple loop:</p>
<ol>
<li><p>Feed in the words so far</p>
</li>
<li><p>Get scores for the next word</p>
</li>
<li><p><strong>Temperature:</strong> Controls creativity. Low = safe/predictable, high = wild/creative</p>
</li>
<li><p><strong>Top-K:</strong> Only consider the K best options (ignore the 31,950 unlikely words)</p>
</li>
<li><p><strong>Top-P (nucleus):</strong> Dynamically select the smallest set of tokens whose cumulative probability reaches the threshold</p>
</li>
<li><p>Randomly pick one word from the remaining options</p>
</li>
<li><p>Add it to the sentence, go back to step 1</p>
</li>
<li><p>Stop when <code>&lt;eos&gt;</code> is generated or <code>max_new_tokens</code> is reached</p>
</li>
</ol>
<h3 id="heading-loading-the-dataset-and-training">Loading the Dataset and Training</h3>
<p>First, we load the JSONL corpus and tokenize every document into one long sequence of token IDs. Then we split it 90/10 into training and validation sets, and wrap them in a PyTorch Dataset that creates fixed-length chunks for next-token prediction:</p>
<pre><code class="language-python">import json
from tokenizers import Tokenizer
from torch.utils.data import Dataset, DataLoader
from tqdm import tqdm

# Device
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using: {device}")

# Load tokenizer
tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
print(f"Tokenizer loaded. Vocab: {tokenizer.get_vocab_size():,}")

# Load and tokenize corpus
print("Loading corpus...")
all_token_ids = []
with open(DATA_PATH, "r", encoding="utf-8") as f:
    for line in tqdm(f, desc="Tokenizing"):
        doc = json.loads(line)
        encoded = tokenizer.encode(doc["text"])
        all_token_ids.extend(encoded.ids)

all_token_ids = torch.tensor(all_token_ids, dtype=torch.long)
print(f"Total tokens: {len(all_token_ids):,}")
</code></pre>
<pre><code class="language-python">class UrduTextDataset(Dataset):
    def __init__(self, token_ids, seq_len):
        self.token_ids = token_ids
        self.seq_len = seq_len
        self.n_chunks = (len(token_ids) - 1) // seq_len

    def __len__(self):
        return self.n_chunks

    def __getitem__(self, idx):
        start = idx * self.seq_len
        chunk = self.token_ids[start:start + self.seq_len + 1]
        return chunk[:-1], chunk[1:]  # input, target (shifted by 1)

config = UrduLLMConfig()

# Split 90/10
split_idx = int(len(all_token_ids) * 0.9)
train_dataset = UrduTextDataset(all_token_ids[:split_idx], config.max_seq_len)
val_dataset = UrduTextDataset(all_token_ids[split_idx:], config.max_seq_len)

train_loader = DataLoader(train_dataset, batch_size=config.batch_size, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=config.batch_size)

print(f"Train: {len(train_dataset):,} chunks")
print(f"Val: {len(val_dataset):,} chunks")
</code></pre>
<p>Each chunk is 256 tokens long. <code>__getitem__</code> returns <code>(input, target)</code> where target is the input shifted by one position, which is exactly what next-token prediction needs.</p>
<p>Training for me took around 3 hours and completed 3 epochs. In essence, it should have done 10 epochs, but after 3 I reached the free limit of Google Colab. Since the purpose of training was learning, I used the model that was generated and saved it in Drive.</p>
<p>Here's the complete training code:</p>
<pre><code class="language-python"># Optimizer
optimizer = torch.optim.AdamW(model.parameters(), lr=config.learning_rate, weight_decay=config.weight_decay)

# LR Schedule
total_steps = len(train_loader) * config.max_epochs
def get_lr(step):
    if step &lt; config.warmup_steps:
        return config.learning_rate * step / config.warmup_steps
    progress = (step - config.warmup_steps) / (total_steps - config.warmup_steps)
    return config.learning_rate * 0.5 * (1 + math.cos(math.pi * progress))

# Training
history = {'train_loss': [], 'val_loss': []}
global_step = 0
best_val_loss = float('inf')

for epoch in range(config.max_epochs):
    model.train()
    epoch_loss = 0
    pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}")

    for input_ids, targets in pbar:
        input_ids, targets = input_ids.to(device), targets.to(device)

        lr = get_lr(global_step)
        for g in optimizer.param_groups:
            g['lr'] = lr

        outputs = model(input_ids, targets)
        loss = outputs['loss']

        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), config.grad_clip)
        optimizer.step()

        epoch_loss += loss.item()
        global_step += 1
        pbar.set_postfix({'loss': f'{loss.item():.4f}'})

    # Validation
    model.eval()
    val_loss = 0
    with torch.no_grad():
        for input_ids, targets in val_loader:
            input_ids, targets = input_ids.to(device), targets.to(device)
            val_loss += model(input_ids, targets)['loss'].item()
    val_loss /= len(val_loader)

    train_loss = epoch_loss / len(train_loader)
    history['train_loss'].append(train_loss)
    history['val_loss'].append(val_loss)

    print(f"Epoch {epoch+1}: Train={train_loss:.4f}, Val={val_loss:.4f}")

    # Save best
    if val_loss &lt; best_val_loss:
        best_val_loss = val_loss
        torch.save(model.state_dict(), f"{DRIVE_PATH}/best_model.pt")
        print(f"Best model saved!")

print(f"\nDone! Best val loss: {best_val_loss:.4f}")
</code></pre>
<p>Now let's break down what each part of the training code does.</p>
<h3 id="heading-training-code-explained-line-by-line">Training Code Explained: Line by Line</h3>
<h4 id="heading-1-optimizer-setup">1. Optimizer Setup</h4>
<pre><code class="language-python">optimizer = torch.optim.AdamW(model.parameters(), lr=config.learning_rate, weight_decay=config.weight_decay)
</code></pre>
<p><code>AdamW</code> maintains two running statistics per parameter (23M × 2 = 46M extra values in memory):</p>
<ul>
<li><p><strong>First moment (momentum):</strong> Exponential moving average of gradients. Smooths out noisy updates so the optimizer doesn't zigzag.</p>
</li>
<li><p><strong>Second moment:</strong> Exponential moving average of squared gradients. Gives each parameter its own adaptive learning rate (frequently updated params get smaller steps, rare ones get larger).</p>
</li>
<li><p><strong>Weight decay (0.1):</strong> Each step, weights are multiplied by <code>(1 - lr × 0.1)</code>, shrinking them slightly. This is <strong>L2 regularization</strong>. It prevents any single weight from growing too large, which reduces overfitting. The "W" in AdamW means this decay is decoupled from the gradient update (applied directly to weights, not mixed into the gradient like vanilla Adam).</p>
</li>
</ul>
<h4 id="heading-2-learning-rate-schedule">2. Learning Rate Schedule</h4>
<pre><code class="language-python">total_steps = len(train_loader) * config.max_epochs  # e.g., 500 batches × 10 epochs = 5000 steps

def get_lr(step):
    if step &lt; config.warmup_steps:                                      # Phase 1: steps 0–499
        return config.learning_rate * step / config.warmup_steps        # Linear ramp: 0 → 3e-4
    progress = (step - config.warmup_steps) / (total_steps - config.warmup_steps)  # 0.0 → 1.0
    return config.learning_rate * 0.5 * (1 + math.cos(math.pi * progress))        # 3e-4 → ~0
</code></pre>
<ul>
<li><p><strong>Warmup (first 500 steps):</strong> At step 0, weights are random and gradients point in semi-random directions, so a large LR would cause destructive parameter updates. By linearly ramping from 0 to 3e-4, we let the loss landscape "stabilize" before making aggressive updates.</p>
</li>
<li><p><strong>Cosine decay (remaining steps):</strong> The formula <code>0.5 × (1 + cos(π × progress))</code> traces a smooth S-curve from 1.0 to 0.0 as progress goes from 0 to 1. Multiplied by peak LR, this gives:</p>
<ul>
<li><p><strong>Early:</strong> Large LR – big parameter changes which results in rapid loss reduction</p>
</li>
<li><p><strong>Late:</strong> Tiny LR – small tweaks which results in fine-tuning without overshooting local minima</p>
</li>
</ul>
</li>
</ul>
<pre><code class="language-plaintext">LR:  0 ──ramp──▶ peak ──smooth curve──▶ ~0
     |  warmup  |     cosine decay      |
</code></pre>
<h4 id="heading-3-tracking-variables">3. Tracking Variables</h4>
<pre><code class="language-python">history = {'train_loss': [], 'val_loss': []}   # For plotting curves later
global_step = 0                                 # Counts total batches across all epochs (for LR schedule)
best_val_loss = float('inf')                    # Tracks best validation; starts at infinity so any real loss beats it
</code></pre>
<h4 id="heading-4-training-loop">4. Training Loop</h4>
<p><strong>Outer Loop: Epochs</strong></p>
<pre><code class="language-python">for epoch in range(config.max_epochs):
    model.train()     # Enables dropout (randomly zeros 10% of activations for regularization)
</code></pre>
<p>Each epoch = one full pass through all training data. We repeat for <code>max_epochs</code> rounds.</p>
<p><strong>Inner Loop: Batches</strong></p>
<p><strong>1. Move to GPU:</strong></p>
<pre><code class="language-python">input_ids, targets = input_ids.to(device), targets.to(device)
</code></pre>
<p>Transfers tensor data from CPU RAM to GPU VRAM. Matrix multiplications in transformers (attention, FFN) run 50–100× faster on GPU due to massive parallelism.</p>
<p><strong>2. Manual LR Update:</strong></p>
<pre><code class="language-python">lr = get_lr(global_step)
for g in optimizer.param_groups:
    g['lr'] = lr
</code></pre>
<p>PyTorch's AdamW doesn't natively support custom schedules, so we manually override the LR each step. <code>param_groups</code> is a list (here just one group), and each group can have its own LR/weight decay.</p>
<p><strong>3. Forward Pass:</strong></p>
<pre><code class="language-python">outputs = model(input_ids, targets)
loss = outputs['loss']
</code></pre>
<p>Input tokens flow through: embeddings → 6 transformer blocks → LM head → logits. Cross-entropy loss is computed between the logits (shape <code>[batch, seq_len, 32000]</code>) and target token IDs. This loss measures the negative log-probability the model assigns to the correct next token, averaged over all positions and batch elements.</p>
<p><strong>4. Backward Pass + Update:</strong></p>
<pre><code class="language-python">optimizer.zero_grad()          # Reset all parameter gradients to zero (they accumulate by default)
loss.backward()                # Backpropagation: compute ∂loss/∂θ for all 23M parameters via chain rule
torch.nn.utils.clip_grad_norm_(model.parameters(), config.grad_clip)  # If ||gradient||₂ &gt; 1.0, scale it down
optimizer.step()               # θ_new = θ_old - lr × adam_adjusted_gradient - lr × weight_decay × θ_old
</code></pre>
<ul>
<li><p><code>zero_grad()</code><strong>:</strong> PyTorch accumulates gradients by default (useful for gradient accumulation across micro-batches). We must manually clear them before each new backward pass.</p>
</li>
<li><p><code>loss.backward()</code><strong>:</strong> Backpropagation traverses the computation graph in reverse, computing ∂loss/∂θ for every parameter using the chain rule. This is the most compute-intensive step alongside the forward pass.</p>
</li>
<li><p><strong>Gradient clipping:</strong> Computes the L2 norm across all parameter gradients concatenated into one vector. If the norm exceeds 1.0, every gradient is multiplied by <code>1.0/norm</code>, preserving direction but capping magnitude. This prevents rare batches (unusual token distributions) from causing catastrophically large updates that destabilize training.</p>
</li>
<li><p><code>optimizer.step()</code><strong>:</strong> AdamW applies the update rule using momentum, adaptive per-parameter LR, and decoupled weight decay.</p>
</li>
</ul>
<p><strong>5. Bookkeeping:</strong></p>
<pre><code class="language-python">epoch_loss += loss.item()      # .item() extracts the Python float from the CUDA tensor (avoids GPU memory leak)
global_step += 1               # Increment for LR schedule
pbar.set_postfix({'loss': ...})  # Update the tqdm progress bar display
</code></pre>
<h4 id="heading-6-validation">6. Validation</h4>
<pre><code class="language-python">model.eval()                   # Disables dropout so we use full model capacity for honest evaluation
val_loss = 0
with torch.no_grad():          # Disables gradient tracking, saves ~50% memory and runs faster
    for input_ids, targets in val_loader:
        input_ids, targets = input_ids.to(device), targets.to(device)
        val_loss += model(input_ids, targets)['loss'].item()
val_loss /= len(val_loader)    # Average loss per batch
</code></pre>
<p>This tests on held-out data the model never trained on. Comparing train vs val loss reveals:</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td>Both decreasing</td>
<td>Model is learning generalizable patterns</td>
</tr>
<tr>
<td>Train ↓, Val stalling/↑</td>
<td>Overfitting: memorizing, not learning</td>
</tr>
<tr>
<td>Both high and flat</td>
<td>Underfitting: model needs more capacity or data</td>
</tr>
</tbody></table>
<p><code>model.eval()</code> turns OFF dropout so we evaluate with the full model. <code>torch.no_grad()</code> skips gradient computation since we're just measuring, not learning.</p>
<h4 id="heading-7-checkpointing">7. Checkpointing</h4>
<pre><code class="language-python">if val_loss &lt; best_val_loss:
    best_val_loss = val_loss
    torch.save(model.state_dict(), f"{DRIVE_PATH}/best_model.pt")
</code></pre>
<p><code>model.state_dict()</code> returns an <code>OrderedDict</code> mapping parameter names onto tensors. <code>torch.save</code> serializes this to disk using Python's pickle + zip. We only save when val loss improves.</p>
<p>This is <strong>early stopping</strong> in spirit: we keep the checkpoint that generalizes best, regardless of what happens in later epochs.</p>
<h4 id="heading-summary-one-batch-in-6-steps">Summary: One Batch in 6 Steps</h4>
<ol>
<li><p>Feed 32 Urdu sequences through the model → get predicted probabilities</p>
</li>
<li><p>Cross-entropy vs actual next tokens → scalar loss (how wrong?)</p>
</li>
<li><p>Backpropagate through 23M parameters → gradient per parameter (what to fix?)</p>
</li>
<li><p>Clip gradient norm to ≤ 1.0 → prevent instability</p>
</li>
<li><p>AdamW updates parameters with momentum + decay → the actual learning</p>
</li>
<li><p>Repeat ~5000 times, save the best checkpoint → done</p>
</li>
</ol>
<h3 id="heading-key-metrics">Key Metrics</h3>
<p><strong>Cross-entropy loss</strong> measures how far the predicted probability distribution is from the true next token. A random model over 32K vocab gets loss ≈ ln(32000) ≈ 10.4</p>
<p><strong>Perplexity = e^loss</strong>, interpretable as "the model is choosing between N equally likely tokens"</p>
<ul>
<li><p>PPL 32,000 = random guessing</p>
</li>
<li><p>PPL 100 = narrowed to ~100 candidates</p>
</li>
<li><p>PPL 10 = quite confident predictions</p>
</li>
</ul>
<p>Once training is completed and we've saved the model in Drive, the next step is to download the model to your local system to perform the next steps.</p>
<p>Now we have a model that's ready, but a question arises: Is it ready to where we can chat with it like we do with any AI tool like ChatGPT, Claude, or Copilot? The answer is <strong>no</strong>, it's not quite ready yet. Why?</p>
<p>The training part is done, but it doesn't know how to structure or write in a conversational manner, like it's answering user queries. This is the step we call <strong>Supervised Fine-Tuning (SFT)</strong>.</p>
<h2 id="heading-4-supervised-fine-tuning-sft">4. Supervised Fine-Tuning (SFT)</h2>
<p>At a very high level, in SFT we teach the model how to respond to queries. It's like giving it examples from which it learns how to answer. The more examples you have, the better the responses will become. So essentially, supervised fine-tuning converts the model to a conversational agent.</p>
<p>To achieve this, we'll create a dataset of examples with the following key pairs and format:</p>
<pre><code class="language-json">{
  "conversations": [
    {"role": "system", "content": "آپ ایک مددگار اردو اسسٹنٹ ہیں۔"},
    {"role": "user", "content": "سوال..."},
    {"role": "assistant", "content": "جواب..."}
  ]
}
</code></pre>
<p>Around <strong>79 examples</strong> get fed to the system and saved in JSONL format. In real cases, you would use many more examples. As I already mentioned, more examples lead to better results.</p>
<h3 id="heading-formatting-conversations-for-training">Formatting Conversations for Training</h3>
<p>The next step is formatting the conversations saved above for training. This is the conversation formatting step for SFT. It converts raw conversation JSON into token ID sequences with <strong>loss masking</strong>, so the model only learns to generate assistant responses.</p>
<p>Loss masking means we intentionally hide certain parts of the input from the training loss. In this case, we mask the system prompt and user message so the model isn't trained to memorize or reproduce them. The training signal comes only from the assistant's response, which is the useful part in teaching the model what to generate and when to stop.</p>
<h4 id="heading-part-1-disable-auto-formatting-amp-get-special-token-ids">Part 1: Disable Auto-Formatting &amp; Get Special Token IDs</h4>
<pre><code class="language-python">tokenizer.no_padding()

BOS_ID = tokenizer.token_to_id("&lt;bos&gt;")       # 2
EOS_ID = tokenizer.token_to_id("&lt;eos&gt;")       # 3
SEP_ID = tokenizer.token_to_id("&lt;sep&gt;")       # 4
PAD_ID = tokenizer.token_to_id("&lt;pad&gt;")       # 0
USER_ID = tokenizer.token_to_id("&lt;|user|&gt;")          # 5
ASSISTANT_ID = tokenizer.token_to_id("&lt;|assistant|&gt;") # 6
SYSTEM_ID = tokenizer.token_to_id("&lt;|system|&gt;")       # 7

IGNORE_INDEX = -100
</code></pre>
<ul>
<li><p><code>no_padding()</code><strong>:</strong> Tells the tokenizer "don't add padding automatically, I'll handle it myself." We need full control over the token sequence.</p>
</li>
<li><p>We fetch the integer IDs for each special token so we can manually insert them at the right positions.</p>
</li>
<li><p><code>IGNORE_INDEX = -100</code><strong>:</strong> PyTorch's <code>cross_entropy</code> has a built-in feature: any label set to -100 is skipped in loss computation. This is how we implement loss masking.</p>
</li>
</ul>
<h4 id="heading-part-2-formatconversation-the-core-function">Part 2: <code>format_conversation()</code>: The Core Function</h4>
<p>This takes a conversation and produces two parallel arrays:</p>
<pre><code class="language-plaintext">input_ids: [BOS, SYSTEM, آپ, ایک, مددگار, ..., SEP, USER, پاکستان, کا, ..., SEP, ASST, اسلام, آباد, ہے, EOS, PAD, PAD, ...]
labels:    [-100, -100, -100, -100, -100, ..., -100, -100, -100,    -100,..., -100, -100, اسلام, آباد, ہے, EOS, -100, -100, ...]
</code></pre>
<p><strong>Step-by-step inside the function:</strong></p>
<p>1. Start with BOS:</p>
<pre><code class="language-python">input_ids = [BOS_ID]
labels = [IGNORE_INDEX]    # Don't learn to predict BOS
</code></pre>
<p>2. For each turn, encode the content and strip auto-added BOS/EOS:</p>
<pre><code class="language-python">content_ids = tokenizer.encode(content).ids
if content_ids[0] == BOS_ID: content_ids = content_ids[1:]     # Remove if tokenizer auto-added
if content_ids[-1] == EOS_ID: content_ids = content_ids[:-1]
</code></pre>
<p>We strip these because we're manually placing special tokens at exact positions, so we don't want duplicates.</p>
<p>3. Build token sequence per role:</p>
<table>
<thead>
<tr>
<th>Role</th>
<th>Token sequence</th>
<th>Labels</th>
</tr>
</thead>
<tbody><tr>
<td>system</td>
<td><code>[SYSTEM_ID] + content + [SEP_ID]</code></td>
<td>All -100 (masked)</td>
</tr>
<tr>
<td>user</td>
<td><code>[USER_ID] + content + [SEP_ID]</code></td>
<td>All -100 (masked)</td>
</tr>
<tr>
<td>assistant</td>
<td><code>[ASST_ID] + content + [EOS_ID]</code></td>
<td><code>[-100] + content + [EOS_ID]</code></td>
</tr>
</tbody></table>
<p>The assistant's role token (<code>&lt;|assistant|&gt;</code>) itself is masked because we don't want the model to learn to predict that. But the actual response content and the <code>&lt;eos&gt;</code> do have labels, so the model learns:</p>
<ul>
<li><p><strong>What to say</strong> (the response content)</p>
</li>
<li><p><strong>When to stop</strong> (predicting <code>&lt;eos&gt;</code>)</p>
</li>
</ul>
<p>4. Truncate and pad:</p>
<pre><code class="language-python">input_ids = input_ids[:max_len]          # Cut to 256 tokens max
pad_len = max_len - len(input_ids)
input_ids = input_ids + [PAD_ID] * pad_len
labels = labels + [IGNORE_INDEX] * pad_len   # Don't learn from padding either
</code></pre>
<p>All sequences must be the same length for batched training. Padding labels are -100 so they're ignored in loss.</p>
<p>Here's the complete <code>format_conversation()</code> function:</p>
<pre><code class="language-python">def format_conversation(conversation: dict, max_len: int = 256) -&gt; dict:
    """
    Convert a conversation dict into token IDs + labels for SFT.

    Format: &lt;bos&gt;&lt;|system|&gt;...&lt;sep&gt;&lt;|user|&gt;...&lt;sep&gt;&lt;|assistant|&gt;...&lt;eos&gt;
    Labels: -100 for system/user tokens (masked), actual IDs for assistant tokens.
    """
    input_ids = [BOS_ID]
    labels = [IGNORE_INDEX]

    for turn in conversation["conversations"]:
        role = turn["role"]
        content = turn["content"]

        content_ids = tokenizer.encode(content).ids
        if content_ids and content_ids[0] == BOS_ID:
            content_ids = content_ids[1:]
        if content_ids and content_ids[-1] == EOS_ID:
            content_ids = content_ids[:-1]

        if role == "system":
            role_ids = [SYSTEM_ID] + content_ids + [SEP_ID]
            role_labels = [IGNORE_INDEX] * len(role_ids)
        elif role == "user":
            role_ids = [USER_ID] + content_ids + [SEP_ID]
            role_labels = [IGNORE_INDEX] * len(role_ids)
        elif role == "assistant":
            role_ids = [ASSISTANT_ID] + content_ids + [EOS_ID]
            role_labels = [IGNORE_INDEX] + content_ids + [EOS_ID]

        input_ids.extend(role_ids)
        labels.extend(role_labels)

    # Truncate and pad to max_len
    input_ids = input_ids[:max_len]
    labels = labels[:max_len]
    pad_len = max_len - len(input_ids)
    input_ids = input_ids + [PAD_ID] * pad_len
    labels = labels + [IGNORE_INDEX] * pad_len

    return {"input_ids": input_ids, "labels": labels}
</code></pre>
<h4 id="heading-part-3-verification">Part 3: Verification</h4>
<pre><code class="language-python">n_loss_tokens = sum(1 for l in test_formatted['labels'] if l != IGNORE_INDEX)
print(f"  Tokens with loss: {n_loss_tokens} / 256")
</code></pre>
<p>This confirms that only a small fraction of tokens (the assistant's words + EOS) contribute to the loss. For a typical example, you might see something like <code>Tokens with loss: 18 / 256</code>, meaning only ~7% of the sequence drives gradient updates. The rest (system prompt, user questions, special tokens, padding) is masked with <code>-100</code>.</p>
<p>This makes SFT extremely efficient: 100% of the learning signal comes from predicting the assistant's actual response and knowing when to stop (<code>&lt;eos&gt;</code>). That efficiency is especially critical when you only have 79 training examples.</p>
<h3 id="heading-formatting-summary">Formatting Summary</h3>
<table>
<thead>
<tr>
<th>Component</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>no_padding()</code></td>
<td>Take manual control of token placement</td>
</tr>
<tr>
<td>Special token IDs</td>
<td>Insert chat structure markers at exact positions</td>
</tr>
<tr>
<td><code>IGNORE_INDEX = -100</code></td>
<td>PyTorch's built-in mechanism to skip positions in loss</td>
</tr>
<tr>
<td>System/User labels → -100</td>
<td>Don't learn from these (context only)</td>
</tr>
<tr>
<td>Assistant labels → real IDs</td>
<td>Learn to generate responses + when to stop</td>
</tr>
<tr>
<td>Truncation to 256</td>
<td>Match model's context window</td>
</tr>
<tr>
<td>Padding with -100 labels</td>
<td>Batch alignment without polluting the loss</td>
</tr>
</tbody></table>
<h3 id="heading-sft-dataset-amp-dataloader">SFT Dataset &amp; DataLoader</h3>
<pre><code class="language-python">class SFTDataset(Dataset):
    def __init__(self, conversations: list, max_len: int = 256):
        self.examples = []
        for conv in conversations:
            formatted = format_conversation(conv, max_len)
            self.examples.append(formatted)

    def __len__(self):
        return len(self.examples)

    def __getitem__(self, idx):
        return (
            torch.tensor(self.examples[idx]['input_ids'], dtype=torch.long),
            torch.tensor(self.examples[idx]['labels'], dtype=torch.long),
        )
</code></pre>
<p>This wraps all 79 formatted conversations into a PyTorch Dataset. At init time, it pre-formats every conversation using <code>format_conversation()</code> and stores the results. When the DataLoader requests item <code>idx</code>, it returns <code>(input_ids, labels)</code> as tensors.</p>
<p><strong>DataLoader:</strong></p>
<pre><code class="language-python">sft_loader = DataLoader(sft_dataset, batch_size=4, shuffle=True)
</code></pre>
<ul>
<li><p><code>batch_size=4</code><strong>:</strong> Small batch because we only have 79 examples. Larger batches would mean fewer gradient updates per epoch.</p>
</li>
<li><p><code>shuffle=True</code><strong>:</strong> Randomize order each epoch so the model doesn't memorize a fixed sequence of examples.</p>
</li>
</ul>
<h3 id="heading-loading-the-pre-trained-model">Loading the Pre-trained Model</h3>
<pre><code class="language-python">model = UrduGPT(config).to(device)
checkpoint = torch.load("best_model.pt", map_location=device)
state_dict = checkpoint['model_state_dict']

# Name mapping (Colab → local)
name_mapping = {
    'token_emb.weight': 'token_embedding.weight',
    'pos_emb.weight': 'position_embedding.weight',
    'ln_f.weight': 'ln_final.weight',
    'ln_f.bias': 'ln_final.bias',
    'head.weight': 'lm_head.weight',
}
</code></pre>
<p>This creates a fresh UrduGPT model and loads the pre-trained weights from Phase 3.</p>
<p>You might be wondering: why the name mapping? The model was trained on Google Colab with slightly different variable names (for example, <code>token_emb</code> vs <code>token_embedding</code>). The mapping translates Colab's naming convention to the local code's convention. <code>strict=False</code> in <code>load_state_dict</code> allows loading even if some keys don't match exactly.</p>
<p>Also, why start from pre-trained? Well, SFT builds on top of pre-training. The model already knows Urdu grammar, vocabulary, and facts. SFT just teaches it the conversation format. Starting from random weights would require far more data and training.</p>
<h3 id="heading-sft-training-loop">SFT Training Loop</h3>
<p>Here's the complete SFT training loop:</p>
<pre><code class="language-python">SFT_LR = 2e-5
SFT_EPOCHS = 50
optimizer = torch.optim.AdamW(model.parameters(), lr=SFT_LR, weight_decay=0.01)

sft_history = {'loss': []}
best_loss = float('inf')

for epoch in range(SFT_EPOCHS):
    model.train()
    epoch_loss = 0
    n_batches = 0

    for input_ids, labels in sft_loader:
        input_ids = input_ids.to(device)
        labels = labels.to(device)

        outputs = model(input_ids)
        logits = outputs['logits']

        shift_logits = logits[:, :-1, :].contiguous()
        shift_labels = labels[:, 1:].contiguous()

        loss = F.cross_entropy(
            shift_logits.view(-1, shift_logits.size(-1)),
            shift_labels.view(-1),
            ignore_index=IGNORE_INDEX,
        )

        optimizer.zero_grad(set_to_none=True)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()

        epoch_loss += loss.item()
        n_batches += 1

    avg_loss = epoch_loss / n_batches
    sft_history['loss'].append(avg_loss)

    if avg_loss &lt; best_loss:
        best_loss = avg_loss
        torch.save({
            'model_state_dict': model.state_dict(),
            'config': config.__dict__,
            'epoch': epoch + 1,
            'loss': avg_loss,
        }, "sft_model.pt")

    if (epoch + 1) % 10 == 0 or epoch == 0:
        print(f"Epoch {epoch+1}/{SFT_EPOCHS} | Loss: {avg_loss:.4f}")

print(f"SFT complete! Best loss: {best_loss:.4f}")
</code></pre>
<p>Why these hyperparameters differ from pre-training:</p>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Pre-training</th>
<th>SFT</th>
<th>Why different</th>
</tr>
</thead>
<tbody><tr>
<td>Learning rate</td>
<td>3e-4</td>
<td>2e-5</td>
<td>Lower LR prevents catastrophic forgetting. Large updates would erase the Urdu knowledge learned during pre-training</td>
</tr>
<tr>
<td>Epochs</td>
<td>3</td>
<td>50</td>
<td>Only 79 examples vs millions of tokens. The model needs many passes to learn the conversation pattern</td>
</tr>
<tr>
<td>Weight decay</td>
<td>0.1</td>
<td>0.01</td>
<td>Less regularization needed since we want the model to fit these specific examples closely</td>
</tr>
<tr>
<td>LR schedule</td>
<td>Cosine warmup</td>
<td>Constant</td>
<td>Simple and effective for small-data fine-tuning</td>
</tr>
</tbody></table>
<p>Here's the training step (per batch):</p>
<pre><code class="language-python"># Forward pass with no targets; we compute loss manually
outputs = model(input_ids)
logits = outputs['logits']

# Shift for next-token prediction
shift_logits = logits[:, :-1, :].contiguous()    # Predictions at positions 0..254
shift_labels = labels[:, 1:].contiguous()         # Targets at positions 1..255

# Loss with masking
loss = F.cross_entropy(
    shift_logits.view(-1, shift_logits.size(-1)),
    shift_labels.view(-1),
    ignore_index=IGNORE_INDEX,  # Skip -100 positions
)
</code></pre>
<p>There's a key difference from pre-training: in pre-training, we passed targets directly to <code>model(input_ids, targets)</code> which computed loss internally on ALL tokens. Here we compute loss manually so we can use <code>ignore_index=-100</code> to mask non-assistant positions.</p>
<p><strong>The shift:</strong> <code>logits[:, :-1]</code> and <code>labels[:, 1:]</code> implement next-token prediction. The model's prediction at position <code>i</code> is compared against the actual token at position <code>i+1</code>.</p>
<p>Backward pass + update:</p>
<pre><code class="language-python">optimizer.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
</code></pre>
<p>This is the same as pre-training: clear gradients → backprop → clip to prevent instability → update parameters. Gradient clipping at 1.0 is especially important here since the model is being fine-tuned and some gradients can be large on small data.</p>
<p>Checkpointing:</p>
<pre><code class="language-python">if avg_loss &lt; best_loss:
    torch.save({'model_state_dict': model.state_dict(), ...}, "sft_model.pt")
</code></pre>
<p>Save whenever training loss improves. Unlike pre-training, we don't have a separate validation set (79 examples is too few to split), so we checkpoint on training loss.</p>
<h3 id="heading-chat-function-inference">Chat Function: Inference</h3>
<p>Here's the complete chat function:</p>
<pre><code class="language-python">def chat(model, tokenizer, user_message: str, system_prompt: str = None,
         max_tokens: int = 100, temperature: float = 0.7) -&gt; str:
    """Generate a chat response."""
    model.eval()

    if system_prompt is None:
        system_prompt = SYSTEM_PROMPT

    # Build the prompt
    prompt_ids = [BOS_ID, SYSTEM_ID]

    sys_ids = tokenizer.encode(system_prompt).ids
    if sys_ids and sys_ids[0] == BOS_ID: sys_ids = sys_ids[1:]
    if sys_ids and sys_ids[-1] == EOS_ID: sys_ids = sys_ids[:-1]
    prompt_ids.extend(sys_ids)
    prompt_ids.append(SEP_ID)

    prompt_ids.append(USER_ID)
    user_ids = tokenizer.encode(user_message).ids
    if user_ids and user_ids[0] == BOS_ID: user_ids = user_ids[1:]
    if user_ids and user_ids[-1] == EOS_ID: user_ids = user_ids[:-1]
    prompt_ids.extend(user_ids)
    prompt_ids.append(SEP_ID)

    prompt_ids.append(ASSISTANT_ID)

    # Generate
    input_tensor = torch.tensor([prompt_ids], dtype=torch.long).to(device)
    with torch.no_grad():
        output_ids = model.generate(
            input_tensor,
            max_new_tokens=max_tokens,
            temperature=temperature,
            top_k=50,
            top_p=0.9,
            eos_token_id=EOS_ID,
        )

    # Decode only the generated part
    generated_ids = output_ids[0][len(prompt_ids):].tolist()
    if EOS_ID in generated_ids:
        generated_ids = generated_ids[:generated_ids.index(EOS_ID)]

    return tokenizer.decode(generated_ids)
</code></pre>
<p>And here's a step-by-step breakdown:</p>
<p><strong>1. Build the prompt:</strong></p>
<pre><code class="language-python">prompt_ids = [BOS_ID, SYSTEM_ID]
prompt_ids.extend(sys_ids)          # System prompt content
prompt_ids.append(SEP_ID)
prompt_ids.append(USER_ID)
prompt_ids.extend(user_ids)          # User message content
prompt_ids.append(SEP_ID)
prompt_ids.append(ASSISTANT_ID)      # "Now respond..."
</code></pre>
<p>This constructs exactly the same format the model saw during SFT training:</p>
<pre><code class="language-plaintext">&lt;bos&gt;&lt;|system|&gt;آپ ایک مددگار...&lt;sep&gt;&lt;|user|&gt;پاکستان کا دارالحکومت؟&lt;sep&gt;&lt;|assistant|&gt;
</code></pre>
<p>The model sees <code>&lt;|assistant|&gt;</code> and knows "I should generate a response now" because during SFT, it learned that tokens after <code>&lt;|assistant|&gt;</code> are what it should produce.</p>
<p><strong>2. Generate autoregressively:</strong></p>
<pre><code class="language-python">with torch.no_grad():
    output_ids = model.generate(
        input_tensor,
        max_new_tokens=max_tokens,
        temperature=temperature,
        top_k=50,
        top_p=0.9,
        eos_token_id=EOS_ID,
    )
</code></pre>
<ul>
<li><p><code>torch.no_grad()</code><strong>:</strong> No gradients needed for inference, which saves memory and speed</p>
</li>
<li><p><code>temperature=0.7</code><strong>:</strong> Slightly sharpened distribution for coherent but not robotic output</p>
</li>
<li><p><code>top_k=50</code><strong>:</strong> Only sample from top 50 tokens to avoid low-probability noise</p>
</li>
<li><p><code>top_p=0.9</code><strong>:</strong> Nucleus sampling that dynamically selects the smallest set of tokens whose cumulative probability ≥ 0.9</p>
</li>
<li><p><code>eos_token_id</code><strong>:</strong> Stop generating when <code>&lt;eos&gt;</code> is produced</p>
</li>
</ul>
<p><strong>3. Extract and decode:</strong></p>
<pre><code class="language-python">generated_ids = output_ids[0][len(prompt_ids):].tolist()    # Only the new tokens
if EOS_ID in generated_ids:
    generated_ids = generated_ids[:generated_ids.index(EOS_ID)]  # Trim at EOS
return tokenizer.decode(generated_ids)
</code></pre>
<p>We slice off the prompt (we don't want to return the system prompt and user message back), trim at <code>&lt;eos&gt;</code>, and decode token IDs back to Urdu text.</p>
<h2 id="heading-5-deployment">5. Deployment</h2>
<p>At this point, you have your own LLM. That's a great milestone. But there's still the classic problem: "it works on my machine."</p>
<p>To make the model public so others can use it too, we need to deploy it and provide an interface for users to interact with.</p>
<p>While exploring deployment options, I came across Gradio, which provides a simple, clean interface for deploying machine learning models and applications. Gradio integrates directly with Hugging Face Spaces, giving us free hosting with minimal setup.</p>
<h3 id="heading-gradio-web-interface-apppy">Gradio Web Interface (<code>app.py</code>)</h3>
<p>The <code>app.py</code> file ties everything together: it loads the tokenizer and model, defines the <code>chat()</code> function, and launches a Gradio UI. The model loading and <code>chat()</code> logic are identical to what we covered in the SFT section, so here we only show the Gradio-specific part:</p>
<pre><code class="language-python">import gradio as gr

def respond(message, history):
    if not message.strip():
        return "براہ کرم کچھ لکھیں۔"
    return chat(message)

demo = gr.ChatInterface(
    fn=respond,
    title="🇵🇰 اردو LLM چیٹ بوٹ",
    description="""
    ### ایک چھوٹا اردو زبان ماڈل جو شروع سے تیار کیا گیا ہے
    **A small Urdu language model built from scratch (~23M parameters)**
    """,
    examples=[
        "السلام علیکم",
        "پاکستان کا دارالحکومت کیا ہے؟",
        "لاہور کے بارے میں بتائیں۔",
        "بریانی کیسے بنتی ہے؟",
        "کرکٹ کیسے کھیلی جاتی ہے؟",
        "چاند کیسے چمکتا ہے؟",
        "رمضان کیا ہے؟",
        "علامہ اقبال کون تھے؟",
        "خوش کیسے رہیں؟",
        "آپ کون ہیں؟",
    ],
    theme=gr.themes.Soft(),
)

if __name__ == "__main__":
    demo.launch()
</code></pre>
<ul>
<li><p><code>respond()</code> wraps <code>chat()</code> with an empty-input guard, matching the signature Gradio's <code>ChatInterface</code> expects.</p>
</li>
<li><p><code>gr.ChatInterface</code> provides a ready-made chat UI with message history, input box, and send button.</p>
</li>
<li><p><code>examples</code> are pre-filled messages users can click to try.</p>
</li>
<li><p><code>theme=gr.themes.Soft()</code> gives a clean, modern visual theme.</p>
</li>
</ul>
<p><strong>Note:</strong> Hugging Face Spaces runs <code>app.py</code> as a standalone script, so the full <code>app.py</code> in the repository inlines everything into one file: the model config, the complete transformer architecture, model loading with <code>gc.collect()</code> for memory optimization, the <code>chat()</code> function, and the Gradio interface above.</p>
<p>We won't repeat all of that here since it was already covered in the Pre-Training and SFT sections.</p>
<p><strong>Running locally:</strong></p>
<pre><code class="language-bash">python app.py
# Opens at http://127.0.0.1:7860
</code></pre>
<h3 id="heading-deployment-options">Deployment Options</h3>
<h4 id="heading-option-a-hugging-face-spaces-free-recommended">Option A: Hugging Face Spaces (Free, Recommended)</h4>
<p>Hugging Face Spaces provides free CPU hosting for Gradio apps.</p>
<p><strong>What to upload:</strong></p>
<pre><code class="language-plaintext">urdu-llm-chat/
├── app.py                          # Gradio web interface
├── requirements.txt                # torch, tokenizers, gradio
├── README.md                       # Space metadata (sdk: gradio)
├── model/
│   ├── __init__.py
│   ├── config.py
│   ├── transformer.py
│   └── checkpoints/sft_model.pt    # ~90MB trained model weights
└── tokenizer/
    └── urdu_tokenizer/
        └── urdu_bpe_tokenizer.json
</code></pre>
<p><strong>How it works:</strong></p>
<ol>
<li><p>Create a free account on <a href="https://huggingface.co">huggingface.co</a></p>
</li>
<li><p>Create a new Space (SDK: Gradio, Hardware: CPU Basic)</p>
</li>
<li><p>Push files via git: <code>git clone https://huggingface.co/spaces/USERNAME/urdu-llm-chat</code></p>
</li>
<li><p>Copy project files into the cloned repo and push</p>
</li>
<li><p>Hugging Face automatically installs dependencies and runs <code>app.py</code></p>
</li>
<li><p>Your model is live at <code>https://huggingface.co/spaces/USERNAME/urdu-llm-chat</code></p>
</li>
</ol>
<p><strong>Why CPU is fine:</strong> Our model is only 23M parameters (~90MB). Inference takes &lt;1 second on CPU. No GPU needed for serving.</p>
<h4 id="heading-option-b-running-locally">Option B: Running Locally</h4>
<pre><code class="language-bash">cd your-project-directory
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py
</code></pre>
<p>Opens at <code>http://127.0.0.1:7860</code>. Works on any machine with Python 3.9+.</p>
<h4 id="heading-option-c-terminal-chat-no-ui">Option C: Terminal Chat (No UI)</h4>
<p>A lightweight alternative with no Gradio dependency, just terminal input/output. Loads the model and enters an interactive loop:</p>
<pre><code class="language-python">"""
Standalone Chat Inference Script for Urdu LLM

Usage:
    python inference/chat.py
"""

import sys
import torch
from pathlib import Path
from tokenizers import Tokenizer

# Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))

from model.config import UrduLLMConfig
from model.transformer import UrduGPT


def load_model(checkpoint_path: str, device: str = None):
    """Load the fine-tuned model."""
    if device is None:
        if torch.cuda.is_available():
            device = "cuda"
        elif torch.backends.mps.is_available():
            device = "mps"
        else:
            device = "cpu"

    device = torch.device(device)

    config = UrduLLMConfig()
    model = UrduGPT(config).to(device)

    checkpoint = torch.load(checkpoint_path, map_location=device)
    model.load_state_dict(checkpoint['model_state_dict'])
    model.eval()

    return model, config, device


def chat_response(model, tokenizer, config, device, user_message,
                  system_prompt="آپ ایک مددگار اردو اسسٹنٹ ہیں۔",
                  max_tokens=100, temperature=0.7):
    """Generate a chat response."""
    BOS_ID = tokenizer.token_to_id("&lt;bos&gt;")
    EOS_ID = tokenizer.token_to_id("&lt;eos&gt;")
    SEP_ID = tokenizer.token_to_id("&lt;sep&gt;")
    USER_ID = tokenizer.token_to_id("&lt;|user|&gt;")
    ASSISTANT_ID = tokenizer.token_to_id("&lt;|assistant|&gt;")
    SYSTEM_ID = tokenizer.token_to_id("&lt;|system|&gt;")

    # Build prompt
    prompt_ids = [BOS_ID, SYSTEM_ID]

    sys_ids = tokenizer.encode(system_prompt).ids
    if sys_ids and sys_ids[0] == BOS_ID: sys_ids = sys_ids[1:]
    if sys_ids and sys_ids[-1] == EOS_ID: sys_ids = sys_ids[:-1]
    prompt_ids.extend(sys_ids)
    prompt_ids.append(SEP_ID)

    prompt_ids.append(USER_ID)
    user_ids = tokenizer.encode(user_message).ids
    if user_ids and user_ids[0] == BOS_ID: user_ids = user_ids[1:]
    if user_ids and user_ids[-1] == EOS_ID: user_ids = user_ids[:-1]
    prompt_ids.extend(user_ids)
    prompt_ids.append(SEP_ID)

    prompt_ids.append(ASSISTANT_ID)

    # Generate
    input_tensor = torch.tensor([prompt_ids], dtype=torch.long).to(device)
    output_ids = model.generate(
        input_tensor,
        max_new_tokens=max_tokens,
        temperature=temperature,
        top_k=50,
        top_p=0.9,
        eos_token_id=EOS_ID,
    )

    generated_ids = output_ids[0][len(prompt_ids):].tolist()
    if EOS_ID in generated_ids:
        generated_ids = generated_ids[:generated_ids.index(EOS_ID)]

    return tokenizer.decode(generated_ids)


def main():
    print("=" * 60)
    print("🇵🇰  اردو LLM چیٹ بوٹ  🇵🇰")
    print("    Urdu LLM ChatBot")
    print("=" * 60)

    # Load model
    tokenizer_path = PROJECT_ROOT / "tokenizer" / "urdu_tokenizer" / "urdu_bpe_tokenizer.json"

    # Try SFT model first, fall back to pre-trained
    sft_path = PROJECT_ROOT / "model" / "checkpoints" / "sft_model.pt"
    pretrained_path = PROJECT_ROOT / "model" / "checkpoints" / "best_model.pt"

    if sft_path.exists():
        checkpoint_path = sft_path
        print("Loading SFT (conversational) model...")
    elif pretrained_path.exists():
        checkpoint_path = pretrained_path
        print("Loading pre-trained model (not fine-tuned for chat)...")
    else:
        print("❌ No model checkpoint found!")
        print("   Run notebooks 03 and 04 first to train the model.")
        sys.exit(1)

    model, config, device = load_model(str(checkpoint_path))
    tokenizer = Tokenizer.from_file(str(tokenizer_path))

    print(f"Model loaded on {device}")
    print("\nType your message in Urdu. Type 'quit' to exit.\n")
    print("-" * 60)

    while True:
        try:
            user_input = input("\n👤 آپ: ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\nخدا حافظ! 👋")
            break

        if user_input.lower() in ['quit', 'exit', 'q']:
            print("خدا حافظ! 👋")
            break

        if not user_input:
            continue

        response = chat_response(model, tokenizer, config, device, user_input)
        print(f"🤖 بوٹ: {response}")


if __name__ == "__main__":
    main()
</code></pre>
<p>Run it with:</p>
<pre><code class="language-bash">python inference/chat.py
</code></pre>
<pre><code class="language-plaintext">👤 آپ: السلام علیکم
🤖 بوٹ: وعلیکم السلام! میں آپ کی کیا مدد کر سکتا ہوں؟
</code></pre>
<h2 id="heading-full-pipeline-summary">Full Pipeline Summary</h2>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Phase 1</strong></td>
<td>Raw Urdu Text → Clean corpus</td>
</tr>
<tr>
<td><strong>Phase 2</strong></td>
<td>Corpus → BPE Tokenizer (32K vocab)</td>
</tr>
<tr>
<td><strong>Phase 3</strong></td>
<td>Tokenized chunks → Pre-train GPT (23M params, next-token prediction)</td>
</tr>
<tr>
<td><strong>Phase 4</strong></td>
<td>Conversations → SFT with loss masking → Chat model</td>
</tr>
<tr>
<td><strong>Deploy</strong></td>
<td><code>app.py</code> + model weights → Hugging Face Spaces / Local</td>
</tr>
</tbody></table>
<p>You can check out the deployed model here: <a href="https://huggingface.co/spaces/Wisamul/urdu_llm">https://huggingface.co/spaces/Wisamul/urdu_llm</a></p>
<h2 id="heading-results">Results</h2>
<p>The results were pretty interesting. As we had a small training sample, the chances of hallucinations were pretty high, and especially if you can understand Urdu, you'll appreciate the fun part of hallucination. But overall it was working fine.</p>
<p>Here are a few sample interactions with the deployed model:</p>
<pre><code class="language-plaintext">👤 User: السلام علیکم
🤖 Assistant: وعلیکم السلام! میں آپ کی کیا مدد کر سکتا ہوں؟

👤 User: پاکستان کا دارالحکومت کیا ہے؟
🤖 Assistant: پاکستان کا دارالحکومت اسلام آباد ہے۔ یہ شہر 1960 کی دہائی میں تعمیر کیا گیا تھا۔

👤 User: تعلیم کی اہمیت کیا ہے؟
🤖 Assistant: تعلیم انسانی ترقی کی بنیاد ہے۔ یہ نہ صرف علم حاصل کرنے کا ذریعہ ہے بلکہ معاشرے کی تعمیر میں اہم کردار ادا کرتی ہے۔
</code></pre>
<p>For questions close to the training data, the model responds accurately and fluently. For out-of-distribution queries, it tends to hallucinate or repeat fragments. This is expected given the small SFT dataset (79 examples) and model size (23M parameters).</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The goal of all this was to discuss and explain all steps involved in the process of building an LLM. I hope it gives you clarity as to why everyone isn't making their own LLM: factors like cost, training, data collection, and so on. At the same time, going through this process has hopefully helped you appreciate the work that has been done before and what we've achieved here.</p>
<p>We went from raw Urdu text all the way to a deployed chatbot: data cleaning, BPE tokenization, pre-training a GPT-style transformer, supervised fine-tuning with loss masking, and finally a Gradio web interface.</p>
<p>The model is tiny and the dataset is small, but every concept here (attention, next-token prediction, SFT, chat formatting) is exactly what powers production LLMs like GPT-4 and Llama – just at a much larger scale.</p>
<p>If you want to improve on this, the highest-impact next steps would be:</p>
<ol>
<li><p>more SFT data (thousands of examples instead of 79),</p>
</li>
<li><p>a larger model (100M+ parameters), and</p>
</li>
<li><p>RLHF/DPO alignment.</p>
</li>
</ol>
<p>But even at this scale, you now have a concrete understanding of the full LLM pipeline.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The New Definition of Software Engineering in the Age of AI ]]>
                </title>
                <description>
                    <![CDATA[ If you're a software developer today, it's almost impossible to avoid the noise of AI( Artificial Intelligence) and its impact on the industry. You open X or LinkedIn in the morning, and the majority  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-new-definition-of-software-engineering-in-the-age-of-ai/</link>
                <guid isPermaLink="false">69e79e7ce4367278146642bb</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tapas Adhikary ]]>
                </dc:creator>
                <pubDate>Tue, 21 Apr 2026 15:57:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/fdae044d-708e-4a00-93f1-5bcef49097f7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you're a software developer today, it's almost impossible to avoid the noise of AI( Artificial Intelligence) and its impact on the industry. You open X or LinkedIn in the morning, and the majority of the posts you see are the terrifying ones about tech layoffs.</p>
<p>You scroll a little more, and someone is claiming that a new AI tool released last week has already made entry-level developers obsolete. You go to YouTube, and a thumbnail screams that all technologies are dead, all developer jobs are dead, and at the same time, a solo founder claims that they've built a million-dollar full-stack app in five minutes using AI agents.</p>
<p>At some point, you start feeling overwhelmed. You start to question and doubt the nights you've spent learning something, building something. You wonder whether the effort you're putting into mastering a programming language or framework still makes sense. You start asking yourself an extremely uncomfortable question: "<em>Is my career still safe?</em>"</p>
<p>This concern is valid. Instead of dismissing the concern with a lot of motivational talk or toxic positivity, let's do a reality check. The industry is fundamentally changing. Hiring patterns are shifting. Expectations for both junior and senior developers are rising exponentially. And yes, AI is the main catalyst accelerating all these changes.</p>
<p>But there is a massive misunderstanding around what's going on. The narrative that "AI is replacing developers" lacks a lot of details. It has created unnecessary fear because it fails to specify what's actually happening.</p>
<p>Not many devs are coming up to explain these details because a good portion of us are still observing, and some are steering the fear to their individual benefits.</p>
<p>Well, here's my take: AI isn't replacing all software engineers. It's replacing a specific kind of work. The low-level, average, routine execution work is getting replaced with AI much faster than anyone could imagine. As a result, it's forcing us to think of what it means to be a software engineer in today's market.</p>
<p>This article is about that thought process. It's a deep dive into the changing landscape of software development, the shift from effort-based to impact-based engineering, and a practical, actionable roadmap to enable you to remain relevant in the era of AI-assisted coding.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-end-of-the-tutorial-driven-era">The End of the Tutorial-Driven Era</a></p>
</li>
<li><p><a href="#heading-lets-decode-the-ai-is-taking-jobs-myth">Let's Decode the "AI is Taking Jobs" Myth</a></p>
</li>
<li><p><a href="#heading-applying-a-clean-architecture">Applying a Clean Architecture</a></p>
</li>
<li><p><a href="#heading-a-practical-ai-era-engineering-roadmap">A Practical, AI-Era Engineering Roadmap</a></p>
<ul>
<li><p><a href="#heading-step-1-strengthen-your-fundamentals">Step 1: Strengthen Your Fundamentals</a></p>
</li>
<li><p><a href="#heading-step-2-build-real-uncomfortable-systems">Step 2: Build Real (Uncomfortable) Systems</a></p>
</li>
<li><p><a href="#heading-step-3-master-the-art-of-debugging">Step 3: Master the Art of Debugging</a></p>
</li>
<li><p><a href="#heading-step-4-use-ai-as-a-tool-not-as-a-crutch">Step 4: Use AI as a Tool, Not as a Crutch</a></p>
</li>
<li><p><a href="#heading-step-5-establishing-a-strong-proof-of-work">Step 5: Establishing a Strong Proof of Work</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-must-needed-mindset-shift">The Must-Needed Mindset Shift</a></p>
</li>
<li><p><a href="#heading-if-youve-read-this-far">If You've Read This Far...</a></p>
</li>
</ol>
<h2 id="heading-the-end-of-the-tutorial-driven-era">The End of the Tutorial-Driven Era</h2>
<p>Let's step back for a moment and look at how most of us learned to develop software over the last decade or so.</p>
<p>Between 2010 and 2023, the industry was filled with tutorial-driven developers. We learned to build software by following step-by-step instructions.</p>
<p>Applications like TODO Apps, Weather dashboards, or clones of YouTube or Spotify were in high demand among developers. These projects gave us confidence. They helped us memorise syntax, learn how to use libraries, and figure out how to write a basic frontend and backend.</p>
<p>For a long time, this was enough. The goal was simple: "<em>Can I build this full-stack application that works?</em>"</p>
<p>If you could write code, connect to a few APIs, and build a working interface, companies were willing to hire you. They viewed junior developers as an investment. The expectation was that you should be trainable: you would come in, write standard boilerplate code, and learn the complexities of the system architecture on the job. The industry had the budget and patience for that learning curve.</p>
<p>But while memorizing the syntax and completing Udemy courses, the tooling was quietly evolving. Today, AI has taken that to a different extreme.</p>
<p>A significant portion of what we used to learn manually can now be generated, assisted, and suggested by AI in seconds.</p>
<ul>
<li><p>Need a basic Express server setup with rate limiting and CORS integrated? Can be generated.</p>
</li>
<li><p>Need a responsive navigation bar written in React? Can be assisted.</p>
</li>
<li><p>Need a standard SQL query to fetch company data? Can be suggested.</p>
</li>
</ul>
<p>If a machine can do something exponentially faster, cheaper, and reasonably well, that specific task stops being the differentiator in the job market. So, when people say AI is replacing junior developers, what they mean is that AI has automated the execution of these surface-level tasks.</p>
<p>But does it mean developers are no longer needed? No, it means the value of our work has moved up the stack. Building a TODO app, a Weather dashboard, or website clones is no longer a portfolio item. They're just your warm-up exercises.</p>
<h2 id="heading-lets-decode-the-ai-is-taking-jobs-myth">Let's Decode the "AI is Taking Jobs" Myth</h2>
<p>Traditionally, software engineers were given requirements: they wrote code, and they ensured it worked. The value of a software engineer was tied to their work execution. Even in interviews, the emphasis was on effort and memory:</p>
<ul>
<li><p>Can you write a linked list from scratch?</p>
</li>
<li><p>Can you check if this text is a palindrome?</p>
</li>
<li><p>Can you find the duplicates in this array of numbers?</p>
</li>
</ul>
<p>If you were a developer who put in long hours analyzing problem statements, manually debugging critical issues, and hand-crafting thousands of lines of source code, you were seen as a dedicated, high-valued employee.</p>
<p>Today, the effort alone is no longer a metric for success.</p>
<p>If you spend hours writing regular expressions or standard authentication flows that an AI agent can scaffold within two minutes, the industry doesn't reward you for your six hours of hard work. The industry asks: "<em>What value did you add beyond what the machine generated?</em>"</p>
<p>This is an uncomfortable truth, but accepting it could be the turning point in your career. Once you accept that AI can write code, your mindset shifts. You start accepting that you no longer have to worry about your execution speed, and you need to focus on <code>System Composition</code> and <code>Abstract Thinking</code>.</p>
<p>If you're a front-end developer today, your job is no longer limited to translating a Figma design into pixel-perfect React components. An AI coding assistant can do 80% of that in a few constructive prompts. Your job role expectations as a front-end developer are now shifted to:</p>
<ul>
<li><p>When that UI connects to the backend, and 10K users log in concurrently, how does the system behave?</p>
</li>
<li><p>Suppose a customer has an SLA (Service Level Agreement) stating that the dashboard must render with all data in 1.2 seconds on a slow 4G network, in 500 ms on a fast 4G network, and in 12 ms on a 5G network. How do you architect your Next.js application to meet that?</p>
</li>
<li><p>Are you leveraging server-side rendering, static generation, or edge caching correctly?</p>
</li>
<li><p>How does the application behave for users depending on screen readers?</p>
</li>
</ul>
<p>Source code is no longer the primary output. It should be the byproduct of your thinking and reasoning. You need to anticipate edge cases, and most importantly, you need to take ownership.</p>
<p>AI can write an API, but AI can't sit in a meeting with a furious client and explain why the production database went down. AI cann't own the consequences of a system failure. That accountability belongs entirely to you.</p>
<h2 id="heading-applying-a-clean-architecture">Applying a Clean Architecture</h2>
<p>Suppose you ask an LLM to build a complex application, say, an e-commerce product dashboard with sorting, filtering, and pagination. It will gladly generate the code that you'll be able to run and render on the browser. Bur AI has a very peculiar tendency in that it loves to build monoliths.</p>
<p>The AI will likely output a massive 1000+ line React component. The state management, UI rendering, data fetching, and business logic will be clubbed together in a single file. So it'll technically work in the browser, but it will be a nightmare to test, maintain, and scale.</p>
<p>This is where the human software engineers come in. A modern engineer understands <a href="https://www.youtube.com/playlist?list=PLIJrr73KDmRyQVT__uFZvaVfWPdfyMFHC">clean code principles and design patterns</a>. Instead of accepting the monolith AI output blindly, the engineer thinks in terms of LEGO-block compositions of React components.</p>
<p>A capable engineer looks into the requirements and thinks, " We shouldn't put everything in a single file. Let's use the <a href="https://youtu.be/LglWulOqh6k">Compound Components Pattern</a> here to make the UI flexible. Let's use the <a href="https://youtu.be/_LBgDy0j-Os">Slot Pattern</a> to create holes in our layout so consumers of this component can pass in their own custom elements without breaking the underlying logic."</p>
<p>You apply abstract thinking. You ask architectural questions:</p>
<ul>
<li><p>How are we managing side effects vs. the data fetching?</p>
</li>
<li><p>Can we swap out the payment provider later with a very small code change?</p>
</li>
<li><p>What happens if the network drops while the user is filtering?</p>
</li>
</ul>
<p>AI provides us with the bare metal raw materials. We need to provide the engineering discipline on top of it to make it production-ready.</p>
<h2 id="heading-a-practical-ai-era-engineering-roadmap">A Practical, AI-Era Engineering Roadmap</h2>
<p>Now, it's time to think about how to bridge the gap between a tutorial-driven developer and a modern, impact-driven engineer. Here is a practical stage-by-stage roadmap for you.</p>
<h3 id="heading-step-1-strengthen-your-fundamentals">Step 1: Strengthen Your Fundamentals</h3>
<p>You can't use AI effectively if you don't understand the code it generates. In the past, a surface-level knowledge of a framework would have been enough for you to execute your tasks. You might have gotten away without knowing the "under the hood" aspects of it.</p>
<p>Today, AI abstracts the frameworks. If something breaks underneath, you're multiple layers away from the actual problem. Having a strong fundamental knowledge will help you to battle this situation, and you'll enjoy working with AI even more.</p>
<p>You must go deep into the fundamentals of Computer Science &amp; Web Technologies:</p>
<ul>
<li><p>How does the internet work? <a href="https://www.freecodecamp.org/news/computer-networking-fundamentals/">Understand Networking basics</a>.</p>
</li>
<li><p>Don't just learn to write JavaScript promises. Learn about the event loop. Understand the call stack, the microtask queue, and how memory allocation works.</p>
</li>
<li><p>When a React application has a memory leak, AI will struggle to find it if it spans multiple files. You need to know how to use Chrome DevTools memory profilers.</p>
</li>
<li><p>Instead of focusing on random algorithmic puzzles, focus on applied abstract thinking. If you're building a real-time collaborative document editor, how do you manage the data structure for concurrent edits? This is how DSA is tested in this era of technical interviews.</p>
</li>
</ul>
<h3 id="heading-step-2-build-real-uncomfortable-systems">Step 2: Build Real (Uncomfortable) Systems</h3>
<p>Stop building TODO apps. Stop building basic CRUD applications that only work in an ideal, localhost environment. Learn to build systems to handle failures.</p>
<p>Instead of building a generic e-commerce clone, build an Automated E-book Delivery and Waitlist system. For example,</p>
<ul>
<li><p><strong>The stack</strong>: Tanstack Start for the front end, NestJS for the API, Supabase for the database, Razorpay for payment processing, Firebase for social logins, and Resend for email delivery.</p>
</li>
<li><p><strong>The challenge</strong>: Don't be satisfied with just making the happy path work. What happens if the Razorpay webhook fails to reach your server after a user pays? How do you implement a retry mechanism? How do you secure your Supabase database with RLS (Row Level Security) so users can only download the book they paid for? How do you prevent duplicate sign-ups on your waitlist?</p>
</li>
</ul>
<p>When you build systems like this, you naturally run into complex real-world problems. Solving these, you'll build the exact engineering muscles that companies are now desperate to hire.</p>
<h3 id="heading-step-3-master-the-art-of-debugging">Step 3: Master the Art of Debugging</h3>
<p>When the system breaks in production, panic starts. The developers who can stay calm, isolate assumptions, trace problems, and fix them are invaluable.</p>
<p>AI is great at explaining isolated error messages, but it can't easily debug a distributed system where a frontend state mismatch is caused by a race condition in a backend microservice. That's on you to burn the midnight oil and get it done.</p>
<p>As a software developer at any level:</p>
<ul>
<li><p>Learn how to implement structured logging in your code.</p>
</li>
<li><p>Learn how to read a stack trace systematically.</p>
</li>
<li><p>Practice fixing performance bottlenecks without causing regressions in other parts of the application.</p>
</li>
<li><p>Understand <a href="https://www.freecodecamp.org/news/how-to-track-and-analyze-web-vitals-to-improve-seo/">Web Vitals</a> (LCP, CLS, INP, and so on.) and how to profile a slow rendering page.</p>
</li>
</ul>
<h3 id="heading-step-4-use-ai-as-a-tool-not-as-a-crutch">Step 4: Use AI as a Tool, Not as a Crutch</h3>
<p>First of all, stop blind copy-pasting AI responses. Treat AI like an incredibly fast, highly confident, but slightly carefree junior developer.</p>
<ul>
<li><p><strong>Use it for boilerplate</strong>: Need an ExpressJS setup? Zustand store set up? Generate it.</p>
</li>
<li><p><strong>Use it for research</strong>: Learning a new thing like Rust, Go, or Cybersecurity? Prompt the AI to generate a 30-day learning roadmap tailored to your existing programming language knowledge.</p>
</li>
<li><p><strong>Use it for content</strong>: Want to write a READ ME file? Want to brainstorm a DRAFT idea? AI can be your companion.</p>
</li>
<li><p><strong>Use it for scaffolding</strong>: Need to write unit tests for a utility function? Let AI scaffold the test suites.</p>
</li>
</ul>
<p>Note, every time you copy code from an LLM without understanding it, you're creating tech debt unknowingly. Your job is to make the AI's response as optimal as possible for production.</p>
<p>If you prompt an AI to write a complex data aggregation logic, and it outputs 72 lines of reducer function, don't just copy-paste it. Read it line-by-line, and ask yourself: Is this optimal? What's the Big O time complexity of this code? Can I make it more readable?</p>
<h3 id="heading-step-5-establishing-a-strong-proof-of-work">Step 5: Establishing a Strong Proof of Work</h3>
<p>A résumé listing your skills or a certificate from a bootcamp aren't very strong proof of work achievements today.</p>
<p>Strong proof of work looks like:</p>
<ul>
<li><p>A GitHub repository featuring a complex real-world application with a beautifully written README explaining the architectural choices.</p>
</li>
<li><p>Meaningful contributions to the open-source projects where your code had to pass serious reviews from senior maintainers.</p>
</li>
<li><p>Writing deep tech articles or LinkedIn posts explaining how you solved a difficult rendering bug or why you chose a specific database schema for a project.</p>
</li>
<li><p>Participating in a hackathon to build something that is either trendy, or has potential to go viral, or can bring revenue, or a combination of all of these.</p>
</li>
</ul>
<p>Don't just code in silos. Build in public. Explain your thought process socially. When you articulate your engineering thoughts and decisions publicly, it separates you from millions of developers who are just relying on the response from ChatGPT or any other AI tools.</p>
<p>The diagram below captures all five steps visually for you to connect them and revisit at any point in time.</p>
<p><a href="https://www.tapascript.io/techframes/software-developer-roadmap-in-ai-age"><img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/f10119e2-91b5-462c-bcc3-ba0f924a6d2a.png" alt="A Practical Roadmap to Consider" style="display:block;margin:0 auto" width="1008" height="1243" loading="lazy"></a></p>
<p><em>You can download this tech frame and many others</em> <a href="https://www.tapascript.io/techframes"><em>from here</em></a><em>.</em></p>
<h2 id="heading-the-must-needed-mindset-shift">The Must-Needed Mindset Shift</h2>
<blockquote>
<p>"It all begins and ends in your mind. What you give power to, has power over you" - by Leon Brown</p>
</blockquote>
<p>If you're currently looking for a job, you need to immediately stop asking people, "Will I get a Job?" It's the wrong question. You can't be sure you'll get a job if you don't have a convincing reason why a company should hire you.</p>
<p>Instead, look at the job descriptions. Look at the companies you admire. Then ask yourself: "<em>Why should they hire me in today's circumstances?</em>"</p>
<p>If you don't have a convincing answer yet, that's perfectly fine! That's your baseline, and you've identified your skill gap. Your mission now is to bridge that gap.</p>
<p>We've entered a phase where the definition of a software engineer is sharper and more demanding than ever before. The bar is higher, but the expectations are clearer. If you refuse to adapt and insist on staying at the level of simple execution, the path forward will likely be incredibly difficult. You'll compete with AI tools that never sleep and developers who are utilizing those tools to do the work of three people.</p>
<p>But if you embrace the shift and move toward abstract thinking, deep fundamentals, system architecture, and true accountability, the opportunities are limitless. You're no longer competing with everyone. Your competition will be with a small set of developers willing to take up the challenge of evolving.</p>
<p>The software engineering of the future (read: "today") is not about typing code syntax into an editor. It's about understanding what to build, why to build it, how it impacts the business, how to design it to last, and how to use AI as a tool to accelerate things exponentially.</p>
<h2 id="heading-if-youve-read-this-far">If You've Read This Far...</h2>
<p>Thank You!</p>
<p>I'm a Full Stack Software Engineer with more than two decades of experience in building products and people. At present, I'm pushing my startup, <a href="https://www.creowis.com/">CreoWis Technologies</a>, and teaching/mentoring developers on my <a href="https://www.youtube.com/tapasadhikary?sub_confirmation=1">YouTube channel, tapaScript</a>.</p>
<p>I'm thrilled to publish my 50th article on the freeCodeCamp platform, and it makes me exceptionally proud to give back my knowledge to the developer community. If you want to connect with me,</p>
<ul>
<li><p>Follow on <a href="https://www.linkedin.com/in/tapasadhikary/">LinkedIn</a> and <a href="https://x.com/tapasadhikary">X</a></p>
</li>
<li><p>Subscribe to my <a href="https://www.youtube.com/tapasadhikary?sub_confirmation=1">YouTube Channel</a></p>
</li>
<li><p>Catch up with my <a href="https://www.tapascript.io/books/react-clean-code-rule-book">React Clean Code Rules Book</a></p>
</li>
</ul>
<p>See you soon with my next article. Until then, please take care of yourself and keep learning.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ GPT-5.4 vs GLM-5: Is Open Source Finally Matching Proprietary AI? ]]>
                </title>
                <description>
                    <![CDATA[ On March 27, 2026, Zhipu AI quietly pushed an update to their open-weight model line. GLM-5.1, they claim, now performs at 94.6% of Claude Opus 4.6 on coding benchmarks. That's a 28% improvement over  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/gpt-5-4-vs-glm-5-is-open-source-finally-matching-proprietary-ai/</link>
                <guid isPermaLink="false">69dd26ba217f5dfcbd1fdd2c</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ open source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oyedele Tioluwani ]]>
                </dc:creator>
                <pubDate>Mon, 13 Apr 2026 17:24:10 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a3eb30b3-57b6-490a-8fd5-3f25994f61b1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>On March 27, 2026, Zhipu AI quietly pushed an update to their open-weight model line. <a href="https://docs.z.ai/devpack/using5.1">GLM-5.1</a>, they claim, now performs at 94.6% of Claude Opus 4.6 on coding benchmarks. That's a 28% improvement over GLM-5, which was released just six weeks prior.</p>
<p>The open-source story is not slowing down. It's accelerating.</p>
<p>And yet, most of the teams celebrating these headlines can't run the models they're celebrating. Self-hosting GLM-5 requires roughly 1,490GB of memory.</p>
<p>The gap between open and proprietary AI has closed on benchmarks, but "open" and "accessible" aren't the same word. Treating them as synonyms is the most expensive mistake a team can make these days.</p>
<p>What follows is a look at the benchmarks that matter, the infrastructure reality the press releases leave out, and a decision framework for teams that need to ship something.</p>
<p>The two models at the center of this comparison are <a href="https://developers.openai.com/api/docs/models/gpt-5.4">GPT-5.4</a>, OpenAI's most capable, frontier model for professional work, released on March 5, 2026, and&nbsp;<a href="https://artificialanalysis.ai/articles/glm-5-everything-you-need-to-know">GLM-5</a>, the 744-billion-parameter open-weight model from China's Zhipu AI, released on&nbsp;February 11.</p>
<p>GPT-5.4 represents the current ceiling of proprietary AI: a model that unifies coding and reasoning into a single system with a one-million token context window, native computer use, and the full weight of OpenAI's platform behind it.</p>
<p>GLM-5 represents something different: the first open-weight model to crack the Intelligence Index score of 50, trained entirely on domestic Chinese hardware, available for free under an MIT license.</p>
<p>The question now shifts from which model scores higher on a given leaderboard to what the gap between them means for teams making real infrastructure decisions.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-what-glm-5-achieved">What GLM-5 Achieved</a></p>
</li>
<li><p><a href="#heading-where-gpt-54-still-has-the-edge">Where GPT-5.4 Still Has the Edge</a></p>
</li>
<li><p><a href="#heading-open-does-not-mean-accessible">"Open" Does Not Mean "Accessible"</a></p>
</li>
<li><p><a href="#heading-the-right-question-is-not-which-model-wins">The Right Question Is Not Which Model Wins</a></p>
</li>
<li><p><a href="#heading-what-this-moment-means">What This Moment Means</a></p>
</li>
</ul>
<h2 id="heading-what-glm-5-achieved"><strong>What GLM-5 Achieved</strong></h2>
<p><a href="https://z.ai/blog/glm-5">GLM-5</a>&nbsp;is a 744-billion-parameter model with 40 billion active parameters per forward pass. It uses a sparse MoE architecture and was trained on 28.5 trillion tokens.</p>
<p>The model was released February 11, 2026, by Zhipu AI, a Tsinghua University spin-off that IPO'd in Hong Kong and raised $558 million in its last funding round. The license is MIT, which means it's commercially usable without restrictions.</p>
<p>The <a href="https://artificialanalysis.ai/evaluations/artificial-analysis-intelligence-index">Artificial Analysis Intelligence Index v4.0</a> is an independent benchmark that aggregates 10 evaluations spanning agentic tasks, coding, scientific reasoning, and general knowledge.</p>
<p>Unlike single-task benchmarks, it's designed to measure a model's overall capability across the kinds of work people actually pay AI to do. Scores are normalized so that even the best frontier models sit around 50 to 57, preserving meaningful separation between them.</p>
<p>GLM-5 scores 50 on this index, the first time any open-weight model has cracked that threshold. GLM-4.7 scored 42. The eight-point jump came from improvements in agentic performance and a 56-percentage-point reduction in the hallucination rate.</p>
<p>On <a href="https://arena.ai/leaderboard/text">Arena (formerly LMArena)</a>, the human-preference benchmark initiated by UC Berkeley, GLM-5 ranked number one among open models in both Text Arena and Code Arena at launch, putting it on par with Claude Opus 4.5 and Gemini 3 Pro overall. That's a human preference, not an automated benchmark.</p>
<p><a href="https://www.swebench.com/">SWE-bench Verified</a>: 77.8%, the number one open-source score. The only models scoring higher are Claude Opus 4.6 (80.8%) and GPT-5.2 (80.0%). On <a href="https://artificialanalysis.ai/evaluations/humanitys-last-exam">Humanity's Last Exam</a> with tools enabled, GLM-5 scores 50.4, beating GPT-5.2's 45.5.</p>
<p><a href="https://arxiv.org/html/2602.15763v1"><img src="https://cdn.hashnode.com/uploads/covers/629e46c5a6bfa05457952a41/71c6d2eb-b6a0-496b-b0a5-62243024ccb7.png" alt="Bar chart comparing GLM-5 against Claude Opus 4.5, GPT-5.2, Gemini 3 Pro, and DeepSeek-V3.2 across eight benchmarks including Humanity's Last Exam, SWE-bench Verified, and Terminal-Bench 2.0" style="display:block;margin:0 auto" width="1094" height="651" loading="lazy"></a></p>
<p>So GLM-5 is genuinely competitive. But competitive at what, exactly? The Intelligence Index gap tells part of the story. The rest lives in specific benchmarks where GPT-5.4 still pulls ahead.</p>
<h2 id="heading-where-gpt-54-still-has-the-edge"><strong>Where GPT-5.4 Still Has the Edge</strong></h2>
<p><a href="https://artificialanalysis.ai/models/comparisons/gpt-5-4-vs-glm-5#intelligence"><img src="https://cdn.hashnode.com/uploads/covers/629e46c5a6bfa05457952a41/b4130b8a-0ab4-41ac-b62b-e1358e272284.png" alt="Bar chart showing GPT-5.4 scoring 57 and GLM-5 scoring 50 on the Artificial Analysis Intelligence Index v4.0 " style="display:block;margin:0 auto" width="1783" height="537" loading="lazy"></a></p>
<p>The gap is not imaginary. On the <a href="https://artificialanalysis.ai/evaluations/artificial-analysis-intelligence-index?models=gpt-5-4%252Cglm-5%252Cgemini-3-1-pro-preview">Artificial Analysis Intelligence Index</a>, GPT-5.4 scores 57 to GLM-5's 50, tied with Gemini 3.1 Pro Preview for number one out of 427 models.</p>
<p>Terminal-Bench is where the gap is most evident. It measures how well a model performs real-world terminal tasks in actual shell environments: file editing, Git operations, build systems, CI/CD pipelines, and system debugging.</p>
<p>Unlike benchmarks that test whether a model can write code in isolation, Terminal-Bench evaluates whether it can operate a computer the way a developer does.</p>
<p>According to <a href="https://developers.openai.com/api/docs/models/gpt-5.4">OpenAI's API documentation</a>, GPT-5.4 scores 75.1%, a 9.7-point lead over the next proprietary model. If your team does DevOps, infrastructure-as-code, or CI/CD debugging, this benchmark maps directly to your actual job.</p>
<p>Context window is another differentiator. GPT-5.4 handles 1.05 million tokens, while GLM-5 caps at 200,000. For agentic workflows that need to plan across large codebases or synthesize multi-document research, this is not a spec difference but a capability difference.</p>
<p>Native computer use is another advantage. This means the model can interact directly with desktop software through screenshots, mouse commands, and keyboard inputs, without requiring a separate plugin or wrapper.</p>
<p>GPT-5.4 is the first general-purpose OpenAI model with this capability built in, while GLM-5 is text-only with no image input. If you're building agents that interact with UIs or need multimodal reasoning, you can't use GLM-5 for that.</p>
<p>OpenAI also claims a 47% token reduction in tool-heavy workflows through something called tool search, a real efficiency gain if you are paying per token.</p>
<p>On pricing, GPT-5.4 at \(2.50 per million input and \)15.00 per million output is 4.2 times more expensive than <a href="https://artificialanalysis.ai/articles/glm-5-everything-you-need-to-know">GLM-5's API</a>. But long-context pricing doubles above 272,000 tokens to $5.00 per million inputs, a tax you'll feel if you run large-context agents.</p>
<p>There's a deeper issue the benchmark numbers don't capture, and it's most likely to trip up teams who rush to adopt open source.</p>
<h2 id="heading-open-does-not-mean-accessible"><strong>"Open" Does Not Mean "Accessible"</strong></h2>
<p>The MIT license is real, and the weights are downloadable, but running GLM-5 in native BF16 precision requires roughly 1,490GB of memory. The recommended production setup for the FP8 model is eight H200 GPUs, each with 141GB of memory. That's a GPU cluster, not something you spin up on a single workstation.</p>
<p>In dollar terms, a used or leased H100 runs \(15,000 to \)25,000. Eight H200S is not a startup purchase. The infrastructure cost of self-hosting GLM-5 rivals or exceeds that of just calling the OpenAI API for most real-world usage volumes.</p>
<p>There is a quantization path. Quantization is a technique that reduces a model's memory footprint by representing its weights at lower numerical precision&nbsp;– for example, compressing from 16-bit to 2-bit values. It makes large models runnable on smaller hardware, but at the cost of some accuracy.</p>
<p>Unsloth's 2-bit GGUF reduces memory usage to 241GB, which fits within a Mac's 256GB unified memory. But quantization degrades model quality. That 77.8% SWE-bench score is for the full-precision model, and the number you get from a quantized local deployment will be lower.</p>
<p>The honest alternative is to use a hosted GLM-5 API. DeepInfra charges \(0.80 per million input tokens, and Novita charges \)1.00 per million input tokens. You can get the model without the hardware, but then you're not self-hosting. You're just using a cheaper API, and the data sovereignty, privacy, and vendor lock-in arguments all evaporate.</p>
<p>"Open weight" in 2026 increasingly means open to enterprises with GPU clusters, open to researchers with cloud credits, and open to teams willing to accept quality trade-offs from quantization. It doesn't mean open to the median developer who wants to avoid their API bill.</p>
<p>The paradox is real: open weights, but not open access. That doesn't mean the choice is impossible. It just means the choice has to be honest.</p>
<h2 id="heading-the-right-question-is-not-which-model-wins"><strong>The Right Question Is Not Which Model Wins</strong></h2>
<table>
<thead>
<tr>
<th></th>
<th><strong>GLM-5 via API</strong></th>
<th><strong>GPT-5.4</strong></th>
<th><strong>Self-hosted GLM-5</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>Best for</strong></td>
<td>Cost-sensitive, under 200K context</td>
<td>Terminal, computer use, long context</td>
<td>Regulated environments with existing GPU infra</td>
</tr>
<tr>
<td><strong>Pricing</strong></td>
<td>$0.80 per million input (DeepInfra)</td>
<td>$2.50 per million input</td>
<td>Hardware cost only</td>
</tr>
<tr>
<td><strong>Context window</strong></td>
<td>200K tokens</td>
<td>1.05M tokens</td>
<td>200K tokens</td>
</tr>
<tr>
<td><strong>Image input</strong></td>
<td>No</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td><strong>Data sovereignty</strong></td>
<td>No</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td><strong>Self-hosting required</strong></td>
<td>No</td>
<td>No</td>
<td>Yes</td>
</tr>
</tbody></table>
<p>The right model depends entirely on what your team is trying to optimize.</p>
<p>Use GLM-5 via API when cost efficiency is the primary constraint, when data residency isn't a concern for Chinese-origin models, when your workflow doesn't require multimodal or image input, and when context demands stay under 200,000 tokens.</p>
<p>It's also the right choice if you want to experiment with open-weight research or contribute back to it. The GLM-5 API is cheap, and if tokens per dollar is your dominant variable, it's hard to beat.</p>
<p>Use GPT-5.4 when your workflow is terminal-heavy or involves computer use, when long-context coherence above 200,000 tokens matters, when you need multimodal input, or when your team is already embedded in the OpenAI ecosystem.</p>
<p>If response consistency at scale is non-negotiable, the premium you pay is real, but for some workloads, the consistency and capabilities justify it.</p>
<p>Consider self-hosting GLM-5 only when your organization already has GPU cluster infrastructure or the budget to build one, when data sovereignty concerns are documented and specific rather than hypothetical, and when you have the ML infrastructure capabilities to manage deployment, updates, and monitoring. Self-hosting a 744-billion parameter model is not a weekend project.</p>
<p>The break-even math is worth doing. At roughly \(0.80 per million tokens via DeepInfra, a team would need to process over one billion tokens per month before self-hosting on \)15,000 H100 hardware begins to pay off. Most teams don't hit that volume, and the ones that do probably already have the infrastructure.</p>
<p>With this decision framework in place, the question shifts to a larger one. What does this moment mean for how teams should think about open source and proprietary AI?</p>
<h2 id="heading-what-this-moment-means"><strong>What This Moment Means</strong></h2>
<p>The benchmark gap has closed. It's real, significant, and historic. The MMLU gap between open and proprietary models was 17.5 points in late 2023 and is now effectively zero. GLM-5, scoring 50 on the Intelligence Index, the first open-weight model to do so, is a genuine milestone.</p>
<p>But the way the gap closed matters as much as the fact that it closed. It closed through architectural ingenuity like DSA sparse attention, MoE efficiency, and asynchronous reinforcement learning, not through democratized compute.</p>
<p>The models that have closed the gap are still large, still expensive to deploy at full fidelity, and still dominated by Chinese labs with significant institutional backing.</p>
<p>The proprietary moat is no longer because they have better models. It's now a better platform, a better ecosystem, a better context window, better enterprise support, and a deployment path that doesn't require a GPU cluster. It's a narrower moat, but it's still a moat.</p>
<p>The question for 2026 is not whether to choose open source or proprietary. It's what you're getting for the premium you pay, and whether that's worth it for your specific workflow. For some teams, the answer will flip. For many, it won't yet.</p>
<p>Most teams reading this won't do the math. They'll see "open source" and assume it means cheaper. They will see "GLM-5 matches GPT-5.4 on benchmarks" and assume they can swap one for the other with no trade-offs.</p>
<p>Those assumptions are how you end up with a $50,000 GPU cluster you don't know how to operate, or a production outage because your quantized model can't handle long context.</p>
<p>The gap between what a benchmark says and what a model does in your actual environment is where engineering judgment lives. If you outsource that judgment to headlines, you're not saving money. You're just deferring the cost until it shows up as an incident.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Reliable AI Systems. ]]>
                </title>
                <description>
                    <![CDATA[ We've all been there: You open ChatGPT, drop a prompt. "Extract all emails from this sheet and categorize by sentiment." It gives you something close. You correct it, it apologizes, and gives you a ne ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-reliable-ai-systems/</link>
                <guid isPermaLink="false">69d7dc42fa7251682ed20d5b</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ System Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Jide Abdul-Qudus ]]>
                </dc:creator>
                <pubDate>Thu, 09 Apr 2026 17:05:06 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/79cc7c0e-1348-4827-934d-a5677c74c362.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>We've all been there: You open ChatGPT, drop a prompt. "Extract all emails from this sheet and categorize by sentiment." It gives you something close. You correct it, it apologizes, and gives you a new version. You ask for a different format, and suddenly, it's lost all context from earlier, and you're starting over.</p>
<p>Errors like that could be fine for little tasks, but it's a disaster for production systems. The gap between "this worked in my ChatGPT conversation" and "this runs reliably in production" is massive. It's not closed by better prompts. It's closed by <strong>engineering.</strong></p>
<p>This article is about that engineering. You'll learn the architecture patterns, failure modes, and implementation strategies that separate AI experiments from AI products.</p>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<p>In this tutorial, you'll learn how to:</p>
<ul>
<li><p>Understand why AI systems fail differently from traditional software</p>
</li>
<li><p>Identify and prevent the three critical failure modes in production AI</p>
</li>
<li><p>Implement the validator sandwich pattern for consistent outputs</p>
</li>
<li><p>Build observable pipelines with proper monitoring and alerting</p>
</li>
<li><p>Control costs at scale with rate limiting and circuit breakers</p>
</li>
<li><p>Design a complete production-ready AI architecture</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most from this tutorial, you should have:</p>
<ul>
<li><p>Basic understanding of any programming language</p>
</li>
<li><p>Familiarity with REST APIs and asynchronous programming</p>
</li>
<li><p>Experience with at least one LLM API (OpenAI, Anthropic, or similar)</p>
</li>
<li><p>Node.js installed locally (optional, for running code examples)</p>
</li>
</ul>
<p>You don't need to be an expert in any of these. Intermediate knowledge is sufficient.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-makes-ai-systems-fundamentally-different">What Makes AI Systems Fundamentally Different</a></p>
</li>
<li><p><a href="#heading-failure-mode-1-inconsistent-outputs">Failure Mode #1: Inconsistent Outputs</a></p>
</li>
<li><p><a href="#heading-failure-mode-2-silent-failures">Failure Mode #2: Silent Failures</a></p>
</li>
<li><p><a href="#heading-failure-mode-3-uncontrolled-costs">Failure Mode #3: Uncontrolled Costs</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-complete-production-architecture">How to Build a Complete Production Architecture</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-what-makes-ai-systems-fundamentally-different">What Makes AI Systems Fundamentally Different</h2>
<p>Traditional software is <strong>deterministic</strong>. You write <code>if (urgency &gt; 8) { return 'high' }</code> and it does exactly that, every single time. Same input, same output. Forever. You can write unit tests that cover every path. You can predict every failure mode.</p>
<p>AI systems, on the other hand, are <strong>probabilistic</strong>. You ask an large language model (LLM) to classify urgency and sometimes it says "high," sometimes "urgent," sometimes it gives you a 1–10 score, sometimes it writes a paragraph explaining its reasoning. Same input, different outputs, depending on temperature settings, model version, context window, and factors you can't fully control.</p>
<p>Here's what that looks like in practice:</p>
<table>
<thead>
<tr>
<th>Challenge</th>
<th>Traditional systems</th>
<th>AI systems</th>
</tr>
</thead>
<tbody><tr>
<td>Consistency</td>
<td>100% reproducible</td>
<td>Varies per request</td>
</tr>
<tr>
<td>Debugging</td>
<td>Stack traces, logs</td>
<td>"The model just changed its behaviour."</td>
</tr>
<tr>
<td>Testing</td>
<td>Unit tests cover all paths</td>
<td>Can't test all possible outputs</td>
</tr>
<tr>
<td>Deployment</td>
<td>Deploy once, works forever</td>
<td>Degrades over time (data drift)</td>
</tr>
<tr>
<td>Failure modes</td>
<td>Predictable, finite</td>
<td>Creative, infinite</td>
</tr>
</tbody></table>
<p>The engineering challenge is: <strong>how do you build reliability on top of inherent unpredictability?</strong></p>
<p>The answer is not "use a better model." The model is maybe 20% of the solution. The remaining 80% is the system you build around it.</p>
<h2 id="heading-failure-mode-1-inconsistent-outputs">Failure Mode #1: Inconsistent Outputs</h2>
<h3 id="heading-the-problem">The Problem</h3>
<p>You ask the AI to extract a customer email from a support ticket. Sometimes you get the email back. Sometimes you get just the name. Sometimes you get a phone number. The format changes every time. Same prompt, different outputs.</p>
<pre><code class="language-plaintext">Prompt: "Extract the customer email from this support ticket"

Output on Monday:    "john@example.com"
Output on Tuesday:   "Customer email: john@example.com (verified)"
Output on Wednesday:   "John Doe"
Output on Thursday: {
                       "customer_info": {
                         "email": "john@example.com"
                       }
                     }
</code></pre>
<p>All three outputs contain correct information, but you can't parse them programmatically. You can't route tickets, trigger workflow systems, or integrate with other code because your response data lacks consistency.</p>
<h3 id="heading-the-solution-the-validator-sandwich-pattern">The Solution: The Validator Sandwich Pattern</h3>
<p>The validator sandwich pattern (also called the guardrails pattern) ensures the AI system doesn't generate or process the wrong data by sandwiching your AI between two layers of deterministic code.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/613e8e5622b7a41dfe5fefa7/cbb83d63-6f97-4918-ae98-5a68e371284c.png" alt="Diagram showing three layers of the Validator Sandwich Pattern: Input Guardrails (top bun), LLM Processing (meat), and Output Guardrails (bottom bun) with arrows showing data flow" style="display:block;margin:0 auto" width="1024" height="559" loading="lazy">

<p>Essentially, you have three layers:</p>
<ol>
<li><p><strong>The top bun</strong>: Input guardrails (deterministic)</p>
</li>
<li><p><strong>The meat</strong>: The LLM (probabilistic)</p>
</li>
<li><p><strong>The bottom bun</strong>: Output guardrails (deterministic)</p>
</li>
</ol>
<p>Let's break down each layer.</p>
<h3 id="heading-the-top-bun-input-guardrails">The Top Bun: Input Guardrails</h3>
<p>Before anything touches the AI, validate it. Reject garbage immediately, fail fast and cheaply. Here's a basic example with deterministic code that checks the data being received:</p>
<pre><code class="language-typescript">function validateTicketInput(raw): TicketInput {
  // Type checks
  if (!raw.email || typeof raw.email !== "string") {
    throw new ValidationError("Missing or invalid email");
  }

  // Format checks
  if (!isValidEmail(raw.email)) {
    throw new ValidationError(`Invalid email format: ${raw.email}`);
  }

  // Range checks
  if (!raw.body || raw.body.length &lt; 10) {
    throw new ValidationError("Ticket body too short to classify");
  }

  if (raw.body.length &gt; 10000) {
    throw new ValidationError("Ticket body exceeds max length");
  }

  // Return typed, validated input
  return {
    email: raw.email.toLowerCase().trim(),
    subject: raw.subject?.trim() || "No subject",
    body: raw.body.trim(),
    timestamp: new Date(raw.timestamp),
  };
}
</code></pre>
<p>This runs before the LLM is ever called. It's fast, cheap, and deterministic. It catches easy failures immediately.</p>
<h3 id="heading-the-meat-structured-outputs-from-the-llm">The Meat: Structured Outputs from the LLM</h3>
<p>Stop asking the AI for free text. Force it into a schema. Most modern APIs support this directly.</p>
<p>So what does "free text" mean? When you prompt an LLM without constraints, it returns unstructured natural language. The model decides the format. Sometimes it's a sentence, sometimes a paragraph, sometimes it adds extra context you didn't ask for. This makes programmatic parsing nearly impossible.</p>
<p>Forcing it into a schema, on the other hand, means that you explicitly tell the model: "Respond only with JSON matching this exact structure", for example. Modern LLM APIs have built-in features to enforce this. Instead of hoping the AI formats its response correctly, you make it structurally impossible for it to return anything else.</p>
<p>Here's the difference in practice:</p>
<p><strong>Without schema enforcement (free text):</strong></p>
<pre><code class="language-typescript">const response = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{
    role: "user",
    content: "Classify this support ticket as bug, billing, or feature request: " + ticketText
  }]
});

// Response could be:
// "This appears to be a billing issue"
// "billing"
// "Category: Billing (confidence: high)"
// { "type": "billing" }  &lt;- if you're lucky
</code></pre>
<p><strong>With schema enforcement:</strong></p>
<pre><code class="language-typescript">const response = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{
    role: "user",
    content: "Classify this support ticket: " + ticketText
  }],
  response_format: {
    type: "json_schema",
    json_schema: {
      name: "ticket_classification",
      strict: true,
      schema: {
        type: "object",
        properties: {
          category: {
            type: "string",
            enum: ["bug", "billing", "feature", "other"]
          },
          confidence: {
            type: "number",
            minimum: 0,
            maximum: 1
          },
          priority: {
            type: "integer",
            minimum: 1,
            maximum: 5
          }
        },
        required: ["category", "confidence", "priority"],
        additionalProperties: false
      }
    }
  }
});

// Response is GUARANTEED to be:
// { "category": "billing", "confidence": 0.89, "priority": 2 }
</code></pre>
<p>The <code>response_format</code> parameter forces the model to output valid JSON matching your schema. If it can't, the API will retry internally until it does. You get predictable, parseable data every single time.</p>
<p>The key difference: you're making the AI conform to <strong>your</strong> format instead of hoping it does the right thing.</p>
<h3 id="heading-the-bottom-bun-output-guardrails">The Bottom Bun: Output Guardrails</h3>
<p>This is the most critical layer. LLMs will hallucinate. This layer catches those hallucinations before they break your database or confuse your users.</p>
<p>Guardrails are validation checks that run after the LLM responds. Think of them as safety barriers on a highway: they don't prevent the car from moving, but they can stop it from going off the road.</p>
<p>In AI systems, guardrails verify that:</p>
<ol>
<li><p>The output matches your expected schema</p>
</li>
<li><p>The data types are correct</p>
</li>
<li><p>The values fall within acceptable ranges</p>
</li>
<li><p>The business logic makes sense</p>
</li>
</ol>
<p>Alright, now you have a structured response. Now you'll want to validate it aggressively before you use it:</p>
<pre><code class="language-typescript">function validateClassification(raw): Classification {
  const required = ["category", "confidence", "priority", "reasoning"];
  for (const field of required) {
    if (raw[field] === undefined || raw[field] === null) {
      throw new ValidationError(`Missing required field: ${field}`);
    }
  }

  if (!["bug", "billing", "feature", "other"].includes(raw.category)) {
    throw new ValidationError(`Invalid category: ${raw.category}`);
  }

  if (typeof raw.confidence !== "number" || 
      raw.confidence &lt; 0 || raw.confidence &gt; 1) {
    throw new ValidationError(`Invalid confidence: ${raw.confidence}`);
  }

  if (!Number.isInteger(raw.priority) || 
      raw.priority &lt; 1 || raw.priority &gt; 5) {
    throw new ValidationError(`Invalid priority: ${raw.priority}`);
  }

  if (raw.category === "billing" &amp;&amp; raw.priority &gt; 3) {
    logger.warn("Suspicious: billing classified as low priority", raw);
  }

  return raw as Classification;
}
</code></pre>
<p>Validating aggressively means checking everything, not just schema compliance. You're validating:</p>
<ul>
<li><p><strong>Schema compliance</strong>: Does the JSON have the right fields?</p>
</li>
<li><p><strong>Type safety</strong>: Is "confidence" actually a number, not a string?</p>
</li>
<li><p><strong>Range validity</strong>: Is confidence between 0 and 1, not -5 or 999?</p>
</li>
<li><p><strong>Business logic</strong>: Does the combination of fields make sense for your domain?</p>
</li>
<li><p><strong>Confidence thresholds</strong>: Is the AI actually confident in this answer?</p>
</li>
</ul>
<p>If any validation fails, you don't silently accept bad data. You have three options:</p>
<ol>
<li><p><strong>Retry with a clearer prompt</strong>: Ask the model to try again with stricter instructions</p>
</li>
<li><p><strong>Escalate to human review</strong>: Log the failure and route to a review queue</p>
</li>
<li><p><strong>Use a fallback</strong>: Return a safe default value that requires human attention</p>
</li>
</ol>
<h3 id="heading-the-deterministic-rule">The Deterministic Rule</h3>
<p>Here's a rule to follow religiously:</p>
<blockquote>
<p><strong>If it can be solved with an if-statement, don't use AI.</strong></p>
</blockquote>
<p>Email format validation? Use regex. Date parsing? Use a date library. Checking if a string contains a keyword? Use a string method. Math? Use actual math.</p>
<p>AI is expensive and probabilistic. Traditional code is free, instant, and deterministic. Use AI for genuinely ambiguous tasks, extracting meaning from unstructured text, generating content, and reasoning about complex inputs. Let deterministic code handle everything else.</p>
<h2 id="heading-failure-mode-2-silent-failures">Failure Mode #2: Silent Failures</h2>
<h3 id="heading-the-problem">The Problem</h3>
<p>Model hallucinations are quite common in AI workflows, ranging from degraded accuracy to outdated training data to misclassification issues. This is the scariest failure mode because you don't know it's happening.</p>
<p>Consider accuracy drift. You trained your model on 2024 data. It's now mid-2026. Your vendors changed their invoice formats. Your classification accuracy has drifted from 95% down to 71%. You won't know until you do a quarterly audit. And by then, thousands of records have been processed incorrectly.</p>
<p>The principle is simple: <strong>you cannot fix what you cannot see.</strong></p>
<h3 id="heading-the-solution-observable-pipelines">The Solution: Observable Pipelines</h3>
<p>Every production AI system needs observability baked in from day one. Here's how this plays out in a production system:</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/613e8e5622b7a41dfe5fefa7/746f2b2c-9825-46da-b0da-0154575a9dba.jpg" alt="Observable Pipeline Flow showing Input, LLM Processing, Confidence Gate and Monitoring Dashboard Flow" style="display:block;margin:0 auto" width="4320" height="4320" loading="lazy">

<p>In the diagram above:</p>
<ol>
<li><p><strong>Input arrives</strong>: A user request comes in (support ticket, document, query). You log: request ID, timestamp, user ID, input hash (for deduplication).</p>
</li>
<li><p><strong>LLM Processing</strong>: The request goes to your AI model. You log which model was called, how long it took (latency), how many tokens used, what it cost, and critically, the confidence score.</p>
</li>
<li><p><strong>Confidence Gate</strong>: This is where you make a routing decision:</p>
<ul>
<li><p><strong>High confidence (&gt;0.8)</strong>: Auto-process and execute the action</p>
</li>
<li><p><strong>Medium confidence (0.6-0.8)</strong>: Send to human review queue</p>
</li>
<li><p><strong>Low confidence (&lt;0.6)</strong>: Immediate escalation + alert</p>
</li>
</ul>
</li>
<li><p><strong>Monitoring Dashboard</strong>: All this data flows into your observability tools, where you track trends over time.</p>
</li>
</ol>
<p>With monitoring, you can detect issues in your system and address them as soon as possible. Monitoring doesn't just catch problems. It gives you data to diagnose and fix them in hours instead of months.</p>
<h4 id="heading-what-youre-measuring-and-why">What you're measuring and why:</h4>
<table>
<thead>
<tr>
<th><strong>Metric</strong></th>
<th><strong>Why it Matters</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Response Time</td>
<td>API Health, model issues</td>
</tr>
<tr>
<td>Confidence</td>
<td>Model degradation</td>
</tr>
<tr>
<td>Human Override Rate</td>
<td>Output quality problems</td>
</tr>
<tr>
<td>Error Rate</td>
<td>System Failures</td>
</tr>
<tr>
<td>Cost per Request</td>
<td>Budget control</td>
</tr>
<tr>
<td>Token Usage Trend</td>
<td>Prompt efficiency</td>
</tr>
</tbody></table>
<p>The goal is not to remove humans from the loop, it's to <strong>only involve humans when the system is genuinely uncertain.</strong></p>
<h2 id="heading-failure-mode-3-uncontrolled-costs">Failure Mode #3: Uncontrolled Costs</h2>
<h3 id="heading-the-problem">The Problem</h3>
<p>You test your workflow with 10 tickets. It works great and costs 50 cents. You deploy to production. 1,000 requests hit your API. Your bill: $500 for the day.</p>
<p>Or you write a retry loop incorrectly. It creates infinite API calls. Your bill: $5,000 for the day.</p>
<p>Or you're using the most expensive model for everything, including simple tasks that a cheaper model could handle.</p>
<p>The reality: <strong>"works for 10 requests" ≠ "works for 10,000 requests."</strong> Scale changes everything.</p>
<h3 id="heading-the-solution-gated-pipelines-with-circuit-breakers">The Solution: Gated Pipelines with Circuit Breakers</h3>
<p>To move from a fragile prototype to a robust production system, you must abandon the naive approach of directly connecting user inputs to LLM APIs. Instead, implement a <strong>gated pipeline</strong>.</p>
<p>Think of this architecture as a series of blast doors. A request must successfully pass through each gate before it earns the right to cost you money. If any gate closes, the request is rejected cheaply and quickly, protecting your budget and your upstream dependencies.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/613e8e5622b7a41dfe5fefa7/b24b1504-91c7-41e6-b582-996b8ab2d0eb.jpg" alt="Gated Pipeline Architecture" style="display:block;margin:0 auto" width="2816" height="1536" loading="lazy">

<p>From the diagram above, these gates are:</p>
<ol>
<li><p>The rate limiter</p>
</li>
<li><p>The cache check</p>
</li>
<li><p>The request queue</p>
</li>
<li><p>The circuit breaker</p>
</li>
</ol>
<p>Let's examine each one.</p>
<h3 id="heading-gate-1-rate-limiting">Gate 1: Rate limiting</h3>
<p>The first line of defence stops abuse before it enters your system. In standard web development, rate limiting is about protecting the server CPU. In AI development, it's about protecting your wallet.</p>
<h3 id="heading-gate-2-cache-check">Gate 2: Cache check</h3>
<p>The cheapest LLM API call is the one you never have to make. Many AI requests are repeated or highly similar. Cache aggressively.</p>
<h3 id="heading-gate-3-request-queue">Gate 3: Request queue</h3>
<p>LLM APIs are not like standard REST APIs; requests often take 10–30 seconds to complete. If 500 users hit "submit" simultaneously, your server cannot open 500 simultaneous connections without crashing or hitting provider concurrency limits. A request queue solves this by batching requests and processing them at a controlled rate.</p>
<h3 id="heading-gate-4-circuit-breaker">Gate 4: Circuit breaker</h3>
<p>Retry logic is necessary for transient network blips, but it is destructive during a real outage. If an LLM provider is experiencing downtime and returning 500 errors, a naive retry loop will frantically hammer their API, wasting your money on failed requests.</p>
<h3 id="heading-how-to-implement-a-gated-pipeline">How to implement a gated pipeline</h3>
<p>Here's an example implementation showing all four gates working together:</p>
<p><strong>Step 1: Rate Limiter (using Redis)</strong></p>
<pre><code class="language-typescript">import { RateLimiterRedis } from "rate-limiter-flexible";
import Redis from "ioredis";

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: 6379
});

// Rate limiting per user
const userLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: "rl:user",
  points: 100,        
  duration: 3600,     
  blockDuration: 60   
});

// Rate limiting globally 
const globalLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: "rl:global",
  points: 1000,       
  duration: 3600      
});
</code></pre>
<p><strong>Step 2: Cache Layer</strong></p>
<pre><code class="language-typescript">import { createHash } from "crypto";

class AICache {
  private redis: Redis;
  private ttl: number = 3600; 

  hashInput(input: string): string {
    return createHash("sha256").update(input).digest("hex");
  }

  async get(input: string): Promise {
    const key = `ai:cache:${this.hashInput(input)}`;
    const cached = await this.redis.get(key);
    
    if (cached) {
      // Cache hit - free!
      await metrics.increment("ai.cache.hits");
      return JSON.parse(cached);
    }
    
    await metrics.increment("ai.cache.misses");
    return null;
  }

  async set(input: string, result: T): Promise {
    const key = `ai:cache:${this.hashInput(input)}`;
    await this.redis.setex(key, this.ttl, JSON.stringify(result));
  }
}
</code></pre>
<p><strong>Step 3: Request Queue</strong></p>
<pre><code class="language-typescript">import Queue from "bull";

const aiQueue = new Queue("ai-requests", {
  redis: {
    host: process.env.REDIS_HOST,
    port: 6379
  }
});

aiQueue.process(5, async (job) =&gt; {
  // Only 5 simultaneous LLM calls max
  const { ticket } = job.data;
  return await callLLM(ticket);
});

async function enqueueRequest(ticket: Ticket) {
  const job = await aiQueue.add(
    { ticket },
    {
      attempts: 3,
      backoff: {
        type: "exponential",
        delay: 2000
      }
    }
  );
  
  return job.finished(); 
}
</code></pre>
<p><strong>Step 4: Circuit Breaker</strong></p>
<pre><code class="language-typescript">enum CircuitState {
  CLOSED,   
  OPEN,     
  HALF_OPEN 
}

class CircuitBreaker {
  private state = CircuitState.CLOSED;
  private failures = 0;
  private lastFailureTime?: Date;
  private successesInHalfOpen = 0;

  private readonly failureThreshold = 3;
  private readonly openDurationMs = 5 * 60 * 1000; 
  private readonly halfOpenSuccesses = 2;

  async execute(
    fn: () =&gt; Promise,
    fallback?: () =&gt; T
  ): Promise {
    if (this.state === CircuitState.OPEN) {
      const elapsed = Date.now() - (this.lastFailureTime?.getTime() || 0);
      
      if (elapsed &lt; this.openDurationMs) {
        // Still in open state - use fallback or throw
        if (fallback) {
          logger.warn("Circuit OPEN - using fallback");
          return fallback();
        }
        throw new Error("Circuit breaker OPEN - service unavailable");
      }
      
      // Transition to half-open
      this.state = CircuitState.HALF_OPEN;
      logger.info("Circuit transitioning to HALF_OPEN");
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess() {
    if (this.state === CircuitState.HALF_OPEN) {
      this.successesInHalfOpen++;
      
      if (this.successesInHalfOpen &gt;= this.halfOpenSuccesses) {
        // Service recovered - close circuit
        this.state = CircuitState.CLOSED;
        this.failures = 0;
        this.successesInHalfOpen = 0;
        logger.info("Circuit CLOSED - service recovered");
      }
    } else {
      this.failures = 0;
    }
  }

  private onFailure() {
    this.failures++;
    this.lastFailureTime = new Date();

    if (this.state === CircuitState.HALF_OPEN) {
      // Failed during test - back to open
      this.state = CircuitState.OPEN;
      this.successesInHalfOpen = 0;
      logger.error("Circuit reopened during HALF_OPEN test");
    } else if (this.failures &gt;= this.failureThreshold) {
      // Too many failures - open circuit
      this.state = CircuitState.OPEN;
      logger.error(`Circuit OPEN after ${this.failures} failures`);
    }
  }
}
</code></pre>
<p><strong>Step 5: Putting it all together</strong></p>
<pre><code class="language-typescript">const cache = new AICache();
const circuitBreaker = new CircuitBreaker();

async function processWithGatedPipeline(ticket: Ticket) {
  try {
    await userLimiter.consume(ticket.userId);
    await globalLimiter.consume("global");
  } catch (error) {
    throw new Error("Rate limit exceeded. Please try again later.");
  }

  const cacheKey = ticket.body;
  const cached = await cache.get(cacheKey);
  if (cached) {
    logger.info("Cache hit - returning cached result");
    return cached;
  }

  const queuedResult = await enqueueRequest(ticket);

  const result = await circuitBreaker.execute(
    async () =&gt; {
      const classification = await callLLM(ticket);
      await cache.set(cacheKey, classification);
      return classification;
    },
    () =&gt; ({
      category: "other",
      confidence: 0,
      requiresHumanReview: true,
      reason: "service_unavailable"
    })
  );

  return result;
}
</code></pre>
<p>What this achieves:</p>
<ul>
<li><p><strong>Rate limiting</strong>: Prevents abuse and runaway costs</p>
</li>
<li><p><strong>Caching</strong>: 30-40% cost reduction on repeated queries</p>
</li>
<li><p><strong>Queueing</strong>: Prevents server overload during traffic spikes</p>
</li>
<li><p><strong>Circuit breaker</strong>: Fails fast during outages instead of wasting money on retries</p>
</li>
</ul>
<p>Each gate is cheap to operate. Together, they protect your system from the most common production failures.</p>
<h2 id="heading-how-to-build-a-complete-production-architecture">How to Build a Complete Production Architecture</h2>
<p>When you combine all three failure mode solutions-consistent outputs, observability, and cost control, you get a complete production architecture.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/613e8e5622b7a41dfe5fefa7/8c461611-3699-41b4-9f41-1b3e0ad6c22e.jpg" alt="Full Architecture" style="display:block;margin:0 auto" width="2816" height="1536" loading="lazy">

<p>When you solve for all three major failure modes, inconsistent outputs, silent failures, and uncontrolled costs. You graduate from a simple script to a true enterprise-grade system. This architecture doesn't just generate text; it actively protects itself, manages resources, and learns from its mistakes.</p>
<h3 id="heading-the-complete-workflow-implementation">The Complete Workflow Implementation</h3>
<p>Here's how all the pieces we've covered fit together in a single workflow. This brings together the validation functions from Failure Mode #1, the observability from Failure Mode #2, and the gated pipeline from Failure Mode #3:</p>
<pre><code class="language-typescript">class TicketWorkflow {
  async processTicket(rawInput: unknown): Promise&lt;TicketResult&gt; {
    const requestId = generateId();
    const startTime = Date.now();

    try {
      // LAYER 1: Input validation + rate limiting + cache
      const ticket = validateTicketInput(rawInput);
      await rateLimiter.consume(ticket.userId);
      
      const cached = await cache.get(ticket.body);
      if (cached) return { ...cached, source: "cache" };

      // LAYER 2: AI processing with circuit breaker protection
      const classification = await circuitBreaker.execute(() =&gt; 
        classifyTicket(ticket)
      );

      // LAYER 3: Output validation + confidence routing
      const validated = validateClassification(classification);
      
      let action: string;
      if (validated.confidence &gt;= 0.8) {
        await sendToAgent(ticket, validated);
        action = "auto_assigned";
      } else {
        await sendToReviewQueue(ticket, validated);
        action = "needs_review";
      }

      // LAYER 4: Log everything for observability
      await logger.log({
        requestId,
        userId: ticket.userId,
        confidence: validated.confidence,
        action,
        latencyMs: Date.now() - startTime,
        cost: calculateCost(classification.tokensUsed)
      });

      await cache.set(ticket.body, validated);
      return { classification: validated, action };

    } catch (error) {
      await logger.logError(requestId, error);
      throw error;
    }
  }
}
</code></pre>
<p>What each layer does:</p>
<p><strong>Layer 1 (Input)</strong> protects your system from bad data and abuse:</p>
<ul>
<li><p>Validates the ticket has required fields (email, subject, body)</p>
</li>
<li><p>Checks rate limits (prevents one user from overwhelming the system)</p>
</li>
<li><p>Returns cached results if we've seen this exact ticket before</p>
</li>
</ul>
<p><strong>Layer 2 (Orchestration)</strong> is where the AI does its work:</p>
<ul>
<li><p>Calls the LLM with structured output requirements</p>
</li>
<li><p>Wrapped in a circuit breaker (fails fast if the API is down)</p>
</li>
<li><p>Uses the cheapest model that works (Haiku for classification)</p>
</li>
</ul>
<p><strong>Layer 3 (Validation)</strong> ensures the output is safe to use:</p>
<ul>
<li><p>Validates the response matches our schema</p>
</li>
<li><p>Routes based on confidence (high confidence → auto-assign, low → human review)</p>
</li>
<li><p>Never blindly trusts AI output</p>
</li>
</ul>
<p><strong>Layer 4 (Observability)</strong> tracks everything:</p>
<ul>
<li><p>Logs every request with latency, cost, and confidence scores</p>
</li>
<li><p>Sends metrics to your monitoring dashboard</p>
</li>
<li><p>Alerts on anomalies (confidence dropping, costs spiking)</p>
</li>
</ul>
<p>This architecture takes you from "it worked in my ChatGPT demo" to "it runs reliably at 10,000 tickets per day." The code is more complex than a simple API call, but the complexity is intentional. It's what makes the system production-ready.</p>
<h2 id="heading-conclusion-engineering-over-prompting">Conclusion: Engineering Over Prompting</h2>
<p>The teams winning with AI right now aren't winning because they have better models. They're winning because they've built better <strong>systems</strong> around imperfect models.</p>
<p>Any company can call the OpenAI API. The ones that pull ahead are the ones who wrap that API call in validation, observability, cost controls, and thoughtful architecture — the ones who treat AI as a component in an assembly line, not a creative partner in a conversation.</p>
<p>The three things every production AI system needs:</p>
<ol>
<li><p><strong>Structure</strong>: Validators, schemas, deterministic layers that enforce consistency and eliminate unpredictability at the edges.</p>
</li>
<li><p><strong>Visibility</strong>: Logging, monitoring, and alerting so you catch problems in hours, not months. Observable pipelines that let you see exactly what the system is doing and why.</p>
</li>
<li><p><strong>Control</strong>: Rate limits, caching, circuit breakers, and cost gates so scale doesn't turn your experiment into a budget emergency.</p>
</li>
</ol>
<p>Reliable AI workflows aren't about better prompts. They're about better architecture around unreliable components.</p>
<p>If you found this helpful, you can connect with me on <a href="https://www.linkedin.com/in/jideabdqudus/">LinkedIn</a> or subscribe to my <a href="https://www.abdulqudus.com/newsletter/">newsletter</a>. You can also visit my <a href="https://www.abdulqudus.com/">website.</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How the Mixture of Experts Architecture Works in AI Models ]]>
                </title>
                <description>
                    <![CDATA[ Artificial intelligence (AI) has seen remarkable advancements over the years, with AI models growing in size and complexity. Among the innovative approaches gaining traction today is the Mixture of Ex ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-the-mixture-of-experts-architecture-works-in-ai-models/</link>
                <guid isPermaLink="false">69d53c4d5da14bc70e77ff78</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Tue, 07 Apr 2026 17:18:05 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/21b2975b-e6ad-462c-84c7-d966bf2092cb.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Artificial intelligence (AI) has seen remarkable advancements over the years, with AI models growing in size and complexity.</p>
<p>Among the innovative approaches gaining traction today is the <a href="https://www.ibm.com/think/topics/mixture-of-experts">Mixture of Experts (MoE)</a> architecture. This method optimizes AI model performance by distributing processing tasks across specialized subnetworks known as “experts.”</p>
<p>In this article, we’ll explore how this architecture works, the role of sparsity, routing strategies, and its real-world application in the Mixtral model. We’ll also discuss the challenges these systems face and the solutions developed to address them.</p>
<h3 id="heading-well-cover">We'll Cover:</h3>
<ul>
<li><p><a href="#heading-understanding-the-mixture-of-experts-moe-approach">Understanding the Mixture of Experts (MoE) Approach</a></p>
</li>
<li><p><a href="#heading-the-role-of-sparsity-in-ai-models">The Role of Sparsity in AI Models</a></p>
</li>
<li><p><a href="#heading-the-art-of-routing-in-moe-architectures">The Art of Routing in MoE Architectures</a></p>
</li>
<li><p><a href="#heading-load-balancing-challenges-and-solutions">Load Balancing Challenges and Solutions</a></p>
<ul>
<li><p><a href="#heading-real-world-application-the-mixtral-model">Real-World Application: The Mixtral Model</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-understanding-the-mixture-of-experts-moe-approach">Understanding the Mixture of Experts (MoE)&nbsp;Approach</h2>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/71385c3e-47b8-4040-adfd-30d5cb57fcd3.jpg" alt="71385c3e-47b8-4040-adfd-30d5cb57fcd3" style="display:block;margin:0 auto" width="1920" height="1080" loading="lazy">

<p>The Mixture of Experts (MoE) is a machine learning technique that divides an AI model into smaller, specialized networks, each focusing on specific tasks.</p>
<p>This is akin to assembling a team where each member possesses unique skills suited for particular challenges.</p>
<p>The idea isn't new. It dates back to a groundbreaking <a href="https://www.cs.toronto.edu/~hinton/absps/jjnh91.pdf">1991 paper</a> that highlighted the benefits of having separate networks specialize in different training cases.</p>
<p>Fast forward to today, and MoE is experiencing a resurgence, particularly among large language models, which utilize this approach to enhance efficiency and effectiveness.</p>
<p>At its core, this system comprises several components: an input layer, multiple expert networks, a gating network, and an output layer.</p>
<p>The gating network serves as a coordinator, determining which expert networks should be activated for a given task.</p>
<p>By doing so, MoE significantly reduces the need to engage the entire network for every operation. This improves performance and reduces computational overhead.</p>
<h2 id="heading-the-role-of-sparsity-in-ai-models">The Role of Sparsity in AI&nbsp;Models</h2>
<p>An essential concept within MoE architecture is sparsity, which refers to activating only a subset of experts for each processing task.</p>
<p>Instead of engaging all network resources, sparsity ensures that only the relevant experts and their parameters are used. This targeted selection significantly reduces computation needs, especially when dealing with complex, high-dimensional data such as natural language processing tasks.</p>
<p>Sparse models excel because they allow for specialized processing. For example, different parts of a sentence may require distinct types of analysis: one expert might be adept at understanding idioms, while another could specialise in parsing complex grammar structures.</p>
<p>By activating only the necessary experts, MoE models can provide more precise and efficient analysis of the input data.</p>
<h2 id="heading-the-art-of-routing-in-moe-architectures">The Art of Routing in MoE Architectures</h2>
<p>Routing is another critical component of the Mixture of Experts model.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/15cad578-a77d-464b-a97a-8c7240ba6263.png" alt="MoE Router" style="display:block;margin:0 auto" width="1000" height="715" loading="lazy">

<p>The gating network plays a crucial role here, as it determines which experts to activate for each input. A successful routing strategy ensures that the network is capable of selecting the most suitable experts, optimizing performance and maintaining balance across the network.</p>
<p>Typically, the routing process involves predicting which expert will provide the best output for a given input. This prediction is made based on the strength of the connection between the expert and the data.</p>
<p>One popular strategy is the <a href="https://mbrenndoerfer.com/writing/top-k-routing-mixture-of-experts-expert-selection">“top-k” routing</a> method, where the k most suitable experts are chosen for a task. In practice, a variant known as “top-2” routing is often used, activating the best two experts, which balances effectiveness and computational cost.</p>
<h2 id="heading-load-balancing-challenges-and-solutions">Load Balancing Challenges and Solutions</h2>
<p>While MoE models have clear advantages, they also introduce specific challenges, particularly regarding load balancing.</p>
<p>The potential issue is that the gating network might consistently select only a few experts, leading to an uneven distribution of tasks. This imbalance can result in some experts being over-utilised and, consequently, over-trained, while others remain underutilised.</p>
<p>To address this challenge, researchers have developed <a href="https://apxml.com/courses/mixture-of-experts-advanced-implementation/chapter-2-advanced-routing-mechanisms/noisy-top-k-gating">“noisy top-k”</a> gating, a technique introducing Gaussian noise to the selection process. This introduces an element of controlled randomness, promoting a more balanced activation of experts.</p>
<p>By distributing the workload more evenly across experts, this approach mitigates the risk of inefficiencies and ensures that the entire network remains effective.</p>
<h3 id="heading-what-actually-happens-during-an-moe-inference">What Actually Happens During an MoE Inference</h3>
<p>To make the Mixture of Experts architecture more concrete, it helps to walk through what happens during a single request.</p>
<p>Consider a prompt like:</p>
<blockquote>
<p>“Explain why startups fail due to poor cash flow management.”</p>
</blockquote>
<p>In a traditional dense model, every layer and every parameter contribute to generating the response. In an MoE model, the process is more selective.</p>
<p>As the input is processed, each layer passes the token representations to the gating network. This component evaluates all available experts and assigns them scores based on how relevant they are to the input. Instead of activating the full network, the model selects only the top-k experts (commonly two).</p>
<p>For this example, the gating network might select:</p>
<ul>
<li><p>One expert specialized in financial reasoning</p>
</li>
<li><p>Another expert better at structuring causal explanations</p>
</li>
</ul>
<p>Only these selected experts process the input, producing intermediate outputs that are then combined and passed to the next layer. The rest of the experts remain inactive for that token.</p>
<p>This selection and combination process repeats across layers, meaning that at any given point, only a small fraction of the model’s total parameters are being used.</p>
<p>The result is a system that behaves like a large, highly capable model, but executes more like a smaller one in terms of compute. This is the practical advantage of MoE: it doesn’t just improve model capacity, it ensures that capacity is used selectively and efficiently for each request.</p>
<h2 id="heading-real-world-application-the-mixtral-model">Real-World Application: The Mixtral&nbsp;Model</h2>
<p>A compelling example of the Mixture of Experts architecture in action is the <a href="https://huggingface.co/docs/transformers/en/model_doc/mixtral">Mixtral model</a>. This open-source large language model exemplifies how MoE can enhance efficiency in processing tasks.</p>
<p>Each layer of the Mixtral model comprises eight experts, each with seven billion parameters. As the model processes each token of input data, the gating network selects the two most suitable experts. These experts handle the task, and their outputs are combined before moving to the next model layer.</p>
<p>This approach allows Mixtral to deliver high performance despite its seemingly modest size for a large language model. By efficiently utilising resources and ensuring specialised processing, Mixtral stands as a testament to the potential of MoE architectures in advancing AI technology.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The Mixture of Experts architecture represents a significant step forward in developing efficient AI systems. With its focus on specialised processing and resource optimisation, MoE offers numerous benefits, particularly for large-scale language models.</p>
<p>Key concepts like sparsity and effective routing ensure that these models can handle complex tasks with precision, while innovations like noisy top-k gating address the common challenges of load balancing.</p>
<p>Despite its complexity and the need for careful tuning, the MoE approach remains promising in elevating AI model performance. As AI continues to advance, architectures like MoE could play a crucial role in powering the next generation of intelligent systems, offering improved efficiency and specialised processing capabilities.</p>
<p>Hope you enjoyed this article. Signup for <a href="https://www.manishmshiva.me/">my free newsletter</a> to get more articles delivered to your inbox. You can also <a href="https://www.linkedin.com/in/manishmshiva">connect with me</a> on Linkedin.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use the Model Context Protocol to Build a Personal Financial Assistant ]]>
                </title>
                <description>
                    <![CDATA[ LLMs are great at writing market commentary. The problem is they can sound confident even when they haven't looked at any data. That’s fine for casual chat, but it’s not fine if you’re building a feat ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-mcp-to-build-a-personal-financial-assistant/</link>
                <guid isPermaLink="false">69c4104010e664c5dac37aed</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nikhil Adithyan ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 16:41:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d0911eef-bfd9-49f7-92ce-8890d8222efd.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>LLMs are great at writing market commentary. The problem is they can sound confident even when they haven't looked at any data. That’s fine for casual chat, but it’s not fine if you’re building a feature for a product, an internal tool, or anything a user might rely on.</p>
<p>In this guide, we’ll build a small financial assistant that fetches real data by calling tools exposed via the MCP protocol (Model Context Protocol), then computes the numbers in Python. The LLM’s job is only to narrate the computed facts. It doesn't invent metrics, and it doesn't do the math.</p>
<p>By the end, you’ll have two outputs you can actually plug into a product flow: a single-ticker market brief, and a watchlist snapshot that compares multiple tickers on volatility and drawdown, with the tool calls traced so you can see exactly what data was used.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-is-mcp-and-how-does-it-change-the-integration-story">What is MCP, and How Does it Change the Integration Story?</a></p>
</li>
<li><p><a href="#heading-architecture-the-narrator-pattern">Architecture: The “Narrator” Pattern</a></p>
</li>
<li><p><a href="#heading-step-1-mcp-client-wrapper-clientpy">Step 1: MCP Client Wrapper (<code>client.py</code>)</a></p>
</li>
<li><p><a href="#heading-step-2-the-assistant-core-corepy">Step 2: The Assistant Core (<code>core.py</code>)</a></p>
<ul>
<li><p><a href="#heading-1-budgets-and-trace-logging">1. Budgets and Trace Logging</a></p>
</li>
<li><p><a href="#heading-2-parsing-the-request">2. Parsing the Request</a></p>
</li>
<li><p><a href="#heading-3-tool-wrappers-prices-and-fundamentals">3. Tool Wrappers: Prices and Fundamentals</a></p>
</li>
<li><p><a href="#heading-4-deterministic-metrics">4. Deterministic Metrics</a></p>
</li>
<li><p><a href="#heading-5-watchlist-utilities">5. Watchlist Utilities</a></p>
</li>
<li><p><a href="#heading-6-facts-object-and-narration">6. Facts Object and Narration</a></p>
</li>
<li><p><a href="#heading-7-the-orchestration-function-run_assistant">7. The Orchestration Function (<code>run_assistant()</code>)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-demo-1-market-brief-for-one-ticker">Demo 1: Market Brief for One Ticker</a></p>
</li>
<li><p><a href="#heading-demo-2-watchlist-snapshot">Demo 2: Watchlist Snapshot</a></p>
</li>
<li><p><a href="#heading-what-makes-this-shippable-and-what-can-be-improved">What Makes this Shippable, and What Can Be Improved?</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This is a code-first guide. I won’t explain every line of Python, so you should be comfortable reading pandas code, basic async/await patterns, and calling APIs from Python.</p>
<p>Before you start, you’ll need:</p>
<ul>
<li><p>Python 3.10+</p>
</li>
<li><p>An EODHD API key (to access the EODHD MCP server)</p>
</li>
<li><p>An OpenAI API key (for the narration step)</p>
</li>
<li><p>The MCP Python client installed, plus the usual data stack: numpy and pandas</p>
</li>
<li><p>A local environment where you can run async Python code (Jupyter or a normal script both work)</p>
</li>
</ul>
<p>If you’ve never worked with async code before, you can still follow along. Just treat the async functions as "network calls" and focus on how the data flows from tool calls, to deterministic metrics, to narration.</p>
<h2 id="heading-what-is-mcp-and-how-does-it-change-the-integration-story">What is MCP, and How Does it Change the Integration Story?</h2>
<img src="https://cdn-images-1.medium.com/max/1000/0*zHMQKv6lzgY5-X7p" alt="source - https://www.civo.com/blog/what-is-mcp" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>MCP (Model Context Protocol) is a protocol for how an LLM application can discover and call external tools exposed by an MCP server. Instead of hardcoding a bunch of function schemas or building custom connectors per framework, you plug into an MCP server and the tools become “available” in a consistent format.</p>
<p>For product teams, this matters because it reduces integration churn. Tool discovery is predictable, you’re not rewriting wrappers every time your stack changes, and you get a clean separation between the model and the data layer.</p>
<p>In our case, that data layer is EOD Historical Data (EODHD), a market data provider. We’ll use <a href="https://eodhd.com/financial-apis/mcp-server-for-financial-data-by-eodhd">EODHD’s MCP server</a>, which exposes market data tools the assistant can call whenever it needs prices or fundamentals.</p>
<p>One important clarification for this tutorial: we’re using an MCP server purely as the data access layer. The model doesn’t decide which MCP tools to call or what parameters to pass. We'll do that deterministically in Python, then hand the model a facts object and let it write the narrative. This keeps the output grounded and makes the system much easier to trust and debug.</p>
<h2 id="heading-architecture-the-narrator-pattern">Architecture: The “Narrator” Pattern</h2>
<p>Here’s the architecture we’re using in this guide:</p>
<img src="https://cdn-images-1.medium.com/max/1500/1*Ljxbr06gEJSs2QdQnPcQSw.png" alt="Architecture diagram showing request parsing, MCP tool calls to EODHD, deterministic Python metrics, and LLM narration" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The idea is simple: we'll separate “getting facts” from “writing words”. The model only does the second part.</p>
<p>First, the user asks a question like “Give me a 30-day brief for AAPL” or “Compare TSLA, NVDA, AMZN over the last 60 days”. That raw text goes into a tiny parser. The parser is intentionally boring. It only extracts what the system needs to operate: a list of tickers and a lookback window.</p>
<p>Once we have tickers and dates, we fetch data by calling MCP tools on the EODHD MCP server. In this case, our MCP client connects to the EODHD MCP server. So instead of the assistant guessing prices or fundamentals, it calls tools like “get historical prices” and “get fundamentals”. At this point we have raw data. Nothing has been computed yet, and the model has not written a single sentence.</p>
<p>Then Python takes over. This is where we compute everything deterministically: returns, volatility, max drawdown, trend slope, and a simple volatility regime label. For watchlists, we align returns and compute correlation. These numbers are the backbone of the output. If you rerun the same query with the same window, you should get the same metrics.</p>
<p>Only after that do we involve the LLM. We pass it a compact facts object. It contains the metrics we computed, plus a few clean fundamentals fields. The prompt is strict. Use only these facts – no extra numbers and no guessing. The model’s job is to turn the facts into a clean note that feels like something a product would show.</p>
<p>Finally, the assistant returns a structured response object. Not just text. You get:</p>
<ul>
<li><p><code>answer</code> (the narrative)</p>
</li>
<li><p><code>metrics</code> (the exact computed numbers)</p>
</li>
<li><p><code>data_used</code> (tickers, date range, and which tools were called)</p>
</li>
<li><p><code>tool_trace_id</code> (a trace id you can log, debug, or attach to monitoring)</p>
</li>
</ul>
<p>This pattern is B2B-friendly for a very practical reason. It reduces hallucinations because the model isn’t doing analysis. It makes numbers repeatable because Python computes them. And it’s easy to audit because you can always show what data was fetched, what window was used, and which tool calls happened.</p>
<h2 id="heading-step-1-mcp-client-wrapper-clientpy">Step 1: MCP Client Wrapper (<code>client.py</code>)</h2>
<p>Before we touch any “assistant logic”, we need one thing: a tiny MCP client wrapper that opens MCP sessions to the EODHD MCP server and calls tools reliably. That’s it.</p>
<p>This file does three jobs:</p>
<ul>
<li><p>opens a streamable HTTP MCP session</p>
</li>
<li><p>calls a tool with a timeout and a small retry loop</p>
</li>
<li><p>returns the tool output plus a small metadata object we can later attach to logs and traces</p>
</li>
</ul>
<p>Here’s the complete <code>client.py</code>:</p>
<pre><code class="language-python">import time
import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client

class EODHDMCP:
    def __init__(self, apikey, base_url=None):
        self.apikey = apikey
        self.base_url = base_url or "https://mcp.eodhd.dev/mcp"
        self._tools = None

    def _url(self):
        return f"{self.base_url}?apikey={self.apikey}"

    def _open(self):
        return streamable_http_client(self._url())

    async def list_tools(self):
        if self._tools is not None:
            return self._tools

        async with self._open() as (read, write, _):
            async with ClientSession(read, write) as s:
                await s.initialize()
                resp = await s.list_tools()
                self._tools = [t.name for t in resp.tools]
                return self._tools

    async def call_tool(self, name, args, trace_id, timeout_s=25, retries=1):
        last = None

        for attempt in range(retries + 1):
            t0 = time.time()
            try:
                async with self._open() as (read, write, _):
                    async with ClientSession(read, write) as s:
                        await s.initialize()
                        out = await asyncio.wait_for(s.call_tool(name, args), timeout=timeout_s)
                        dt = time.time() - t0
                        meta = {"trace_id": trace_id, "tool": name, "args": args, "latency_s": round(dt, 3)}
                        return out, meta
            except Exception as e:
                last = e
                if attempt &lt; retries:
                    await asyncio.sleep(0.25)

        raise last
</code></pre>
<p>How this works:</p>
<ul>
<li><p><code>streamablehttp_client(self._url())</code> opens an MCP session over streamable HTTP. The URL includes your API key as a query param, so the server can authenticate.</p>
</li>
<li><p><code>list_tools()</code> is just a convenience. It asks the server which tools exist and caches the names in memory so you don’t fetch them repeatedly.</p>
</li>
<li><p><code>call_tool()</code> is the workhorse. It opens a session, initializes it, calls a tool with <code>call_tool(name, args)</code>, and wraps the result with a <code>meta</code> object.</p>
</li>
<li><p>That <code>meta</code> object is important later. It lets you trace which tool was called, with which params, how long it took, and which request it belonged to (<code>trace_id</code>).</p>
</li>
</ul>
<p>Next, we’ll build the core runner in <code>core.py</code>. This is where we parse the user’s request, fetch prices and fundamentals via MCP, compute metrics in Python, and then hand the facts to the LLM for narration.</p>
<h2 id="heading-step-2-the-assistant-core-corepy">Step 2: The Assistant Core (<code>core.py</code>)</h2>
<p>This is where the assistant actually becomes “real”. <code>client.py</code> was just a connector. Here we decide what data to fetch, how much to fetch, how to compute the numbers, and what we hand to the model for narration.</p>
<h3 id="heading-1-budgets-and-trace-logging">1. Budgets and Trace&nbsp;Logging</h3>
<p>When you build anything that calls real tools, you want limits. Not because you don’t trust your code, but because without limits, one messy prompt can easily turn into an expensive, slow request.</p>
<p>In our case, we cap:</p>
<ul>
<li><p>how far back we’ll fetch data (<code>MAX_LOOKBACK_DAYS</code>)</p>
</li>
<li><p>how many tool calls we allow per request (<code>MAX_TOOL_CALLS</code>)</p>
</li>
<li><p>how many tickers we’ll accept in one query (<code>MAX_TICKERS</code>)</p>
</li>
</ul>
<p>And we log a few events so we can always debug what happened later.</p>
<p>Here’s the top part of <code>core.py</code> for that:</p>
<pre><code class="language-python">import json
import re
import time
import uuid
from datetime import date, timedelta
from openai import OpenAI
import numpy as np
import pandas as pd
import asyncio
from client import EODHDMCP

EODHD_API_KEY = "YOUR EODHD API KEY"
MCP_BASE_URL = "https://mcp.eodhd.dev/mcp"

MAX_LOOKBACK_DAYS = 365
MAX_TOOL_CALLS = 6
MAX_TICKERS = 5

mcp = EODHDMCP(EODHD_API_KEY, base_url=MCP_BASE_URL)
oa = OpenAI(api_key = "OPENAI API KEY")
NARRATION_MODEL = "gpt-5.3-chat-latest"

def log_event(event, trace_id, **k):
    payload = {"event": event, "trace_id": trace_id, "ts": round(time.time(), 3)}
    payload.update(k)
    print(json.dumps(payload, default=str))
</code></pre>
<p>What’s going on here:</p>
<ul>
<li><p><code>MAX_LOOKBACK_DAYS</code>, <code>MAX_TOOL_CALLS</code>, <code>MAX_TICKERS</code> are basically your safety rails. We’ll enforce them later, right after parsing the user query.</p>
</li>
<li><p><code>trace_id</code> is a small id we generate per request. Every log line includes it, so when something breaks, you can reconstruct the exact flow for that request.</p>
</li>
<li><p><code>log_event()</code> prints one JSON line. Nothing fancy –&nbsp;but it’s enough for debugging and it also looks very similar to how real systems emit traces.</p>
</li>
</ul>
<p>Note: Make sure to replace <code>YOUR EODHD API KEY</code> with your actual EODHD API key. If you don’t have one, you can obtain it by creating an EODHD developer account.</p>
<h3 id="heading-2-parsing-the-request">2. Parsing the&nbsp;Request</h3>
<p>This part is intentionally not “smart”. We’re not doing NLP. We’re not letting the model interpret the query. We just want to extract two things in a predictable way:</p>
<ul>
<li><p>tickers</p>
</li>
<li><p>lookback window</p>
</li>
</ul>
<p>That’s it.</p>
<p>The benefit of keeping it dumb is that the behavior is stable. If the query is messy, we still do something consistent, and the rest of the pipeline remains controllable.</p>
<p>Here are the two functions:</p>
<pre><code class="language-python">def parse_request(text):
    t = (text or "").upper()

    raw = re.findall(r"\b[A-Z]{1,5}\b", t)

    bad = {
        "I","A","AN","THE","AND","OR","TO","FOR","OF","IN","ON","BY","WITH","ME","WE","US",
        "GIVE","DAY","DAYS","BRIEF","COMPARE","RANK","OVER","LAST","TREND","VOL","VOLATILITY",
        "DRAWDOWN","FLAG","RISKS","RISK","PLUS","MAX","MIN","LOOKBACK"
    }

    tickers = []
    for x in raw:
        if x in bad:
            continue
        if len(x) &lt; 2:
            continue
        if x not in tickers:
            tickers.append(x)

    days = 30

    if "LAST" in t:
        after = t.split("LAST", 1)[1]
        m = re.search(r"\d{1,4}", after)
        if m:
            days = int(m.group(0))
    
    return tickers, days

def enforce_budgets(tickers, lookback_days):
    if lookback_days &lt; 1:
        lookback_days = 1
    if lookback_days &gt; MAX_LOOKBACK_DAYS:
        lookback_days = MAX_LOOKBACK_DAYS

    tickers = tickers[:MAX_TICKERS]

    return tickers, lookback_days
</code></pre>
<p>How to read this:</p>
<ul>
<li><p><code>re.findall(r"\b[A-Z]{1,5}\b", t)</code> pulls out every short uppercase token. That’s our crude “ticker candidate” list.</p>
</li>
<li><p>The <code>bad</code> set is just a blacklist of common words that show up in prompts but are obviously not tickers.</p>
</li>
<li><p>We keep unique tickers in order, because the first ticker becomes the “base” for correlation in the watchlist demo.</p>
</li>
<li><p>Lookback is simple: the default is 30 days. If the query contains “last&nbsp;…”, we grab the first number after “LAST”. That avoids regex edge cases with punctuation.</p>
</li>
</ul>
<p>Then <code>enforce_budgets()</code> clamps everything so one request can’t ask for 500 tickers or a 10-year window.</p>
<p>Next, we’ll wire these parsed values into a request state and start making actual MCP calls for prices and fundamentals.</p>
<h3 id="heading-3-tool-wrappers-prices-and-fundamentals">3. Tool Wrappers: Prices and Fundamentals</h3>
<p>Now we’re at the point where the assistant actually touches data.</p>
<p>These two functions do the same job in different ways:</p>
<ul>
<li><p><code>fetch_prices()</code> calls the historical prices tool on the EODHD MCP server, then normalizes the output into a tiny DataFrame with just <code>date</code> and <code>price</code>.</p>
</li>
<li><p><code>fetch_fundamentals()</code> calls the fundamentals tool on the EODHD MCP server.</p>
</li>
</ul>
<p>We also keep a small <code>state</code> object per request. It tracks tool calls and keeps a trace of what was called. That’s how we later produce the <code>data_used</code> block in the final response.</p>
<p>Here’s the code:</p>
<pre><code class="language-python">def new_state():
    return {"tool_calls": 0, "tool_trace": [], "rows": {}}

def _bump(state, meta):
    state["tool_calls"] += 1
    state["tool_trace"].append(meta)
    if state["tool_calls"] &gt; MAX_TOOL_CALLS:
        raise RuntimeError("tool call budget exceeded")

def _as_json_text(out):
    if isinstance(out, str):
        return out
    if hasattr(out, "content"):
        try:
            return out.content[0].text
        except Exception:
            pass
    return str(out)

async def fetch_prices(ticker, start_date, end_date, trace_id, state):
    args = {
        "ticker": ticker,
        "start_date": start_date,
        "end_date": end_date,
        "period": "d",
        "order": "a",
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_historical_stock_prices", args, trace_id)
    txt = _as_json_text(out)

    _bump(state, meta)

    data = json.loads(txt)
    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    df = pd.DataFrame(data)
    if df.empty:
        return df

    cols = [c for c in ["date", "adjusted_close", "close"] if c in                   df.columns]
    df = df[cols].copy()

    if "adjusted_close" in df.columns:
        df = df.rename(columns={"adjusted_close": "price"})
    elif "close" in df.columns:
        df = df.rename(columns={"close": "price"})
    else:
        return pd.DataFrame()

    df["ticker"] = ticker

    state["rows"][f"{meta['tool']}:{ticker}"] = len(df)
    return df

async def fetch_fundamentals(ticker, trace_id, state):
    args = {
        "ticker": ticker,
        "include_financials": False,
        "fmt": "json",
    }

    out, meta = await mcp.call_tool("get_fundamentals_data", args, trace_id)
    txt = _as_json_text(out)

    _bump(state, meta)

    data = json.loads(txt)
    if isinstance(data, dict) and data.get("error"):
        raise RuntimeError(data["error"])

    return data
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>_bump()</code> is the budget guard. Every time we make a tool call, we increment the counter and store the tool metadata. If we cross the budget, we fail fast.</p>
</li>
<li><p><code>meta</code> comes from <code>client.py</code>. It contains <code>tool</code>, <code>args</code>, and latency. That’s enough to trace “what did we call and how long did it take”.</p>
</li>
<li><p><code>_as_json_text()</code> is there because the tool results returned by the MCP server are not always plain strings. Sometimes it’s an object with&nbsp;<code>.content</code>. This helper just tries to extract the text cleanly.</p>
</li>
<li><p>In <code>fetch_prices()</code>, we intentionally keep only <code>date</code> and <code>price</code>. That’s not because OHLC is useless. It’s because this tutorial’s metrics only need adjusted closes. Fewer columns means simpler code, smaller payloads, and fewer chances to break.</p>
</li>
</ul>
<p>Next, we’ll compute the actual metrics. This is where the assistant stops being “an API caller” and starts producing something useful.</p>
<h3 id="heading-4-deterministic-metrics">4. Deterministic Metrics</h3>
<p>This is the most important design choice in the whole build. The model never computes numbers. Python does.</p>
<p>So for every ticker, we compute a small set of metrics that are easy to explain and are actually useful in a “market brief” style output:</p>
<ul>
<li><p>total return over the window</p>
</li>
<li><p>realized volatility (daily and annualized)</p>
</li>
<li><p>max drawdown (worst peak-to-trough fall)</p>
</li>
<li><p>a simple trend slope (so we can say “mild uptrend” or “downtrend” without vibes)</p>
</li>
<li><p>a lightweight regime label (low, mid, high volatility)</p>
</li>
</ul>
<p>Here’s the code:</p>
<pre><code class="language-python">def compute_metrics(prices_df):
    if prices_df is None or prices_df.empty:
        return {}

    df = prices_df.copy()
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df = df.dropna(subset=["date"]).sort_values("date")

    close = pd.to_numeric(df["price"], errors="coerce").dropna()
    if close.empty:
        return {}

    rets = close.pct_change().dropna()

    out = {}

    # realized vol (daily), annualize with sqrt(252)
    if not rets.empty:
        out["vol_daily"] = float(rets.std())
        out["vol_annualized"] = float(rets.std() * np.sqrt(252))
        out["ret_total"] = float((close.iloc[-1] / close.iloc[0]) - 1.0)

    # max drawdown
    peak = close.cummax()
    dd = (close / peak) - 1.0
    out["max_drawdown"] = float(dd.min())

    # simple trend score
    logp = np.log(close.values)
    x = np.arange(len(logp))
    if len(logp) &gt;= 3:
        slope = np.polyfit(x, logp, 1)[0]
        out["trend_slope"] = float(slope)
    else:
        out["trend_slope"] = 0.0

    # basic helpers
    out["n_points"] = int(len(close))
    out["start_close"] = float(close.iloc[0])
    out["end_close"] = float(close.iloc[-1])

    return out

def compute_regime(prices_df, window=20):
    # cheap regime label, based on rolling vol percentile
    if prices_df is None or prices_df.empty:
        return {"regime": "unknown"}

    df = prices_df.copy()
    df["date"] = pd.to_datetime(df["date"], errors="coerce")
    df = df.dropna(subset=["date"]).sort_values("date")

    close = pd.to_numeric(df["price"], errors="coerce").dropna()
    if close.empty:
        return {"regime": "unknown"}

    rets = close.pct_change()
    rv = rets.rolling(window).std()

    last = rv.dropna()
    if last.empty:
        return {"regime": "unknown"}

    cur = float(last.iloc[-1])
    p80 = float(last.quantile(0.8))
    p50 = float(last.quantile(0.5))

    if cur &gt;= p80:
        reg = "high_vol"
    elif cur &gt;= p50:
        reg = "mid_vol"
    else:
        reg = "low_vol"

    return {"regime": reg, "rolling_vol": cur, "window": int(window)}
</code></pre>
<p>How to think about these calculations:</p>
<ul>
<li><p><strong>Total return</strong> is just <code>end / start - 1</code>. It’s the simplest “did it go up or down” number.</p>
</li>
<li><p><strong>Volatility</strong> here is realized volatility of daily returns. That’s just the standard deviation of daily % changes. We annualize it using <code>sqrt(252)</code> because markets have roughly 252 trading days.</p>
</li>
<li><p><strong>Max drawdown</strong> tells you how bad the worst dip was during the window. It’s often more meaningful than return when you’re writing a quick risk note.</p>
</li>
<li><p><strong>Trend slope</strong> is intentionally simple. We fit a straight line to log prices. If the slope is positive, it’s generally drifting up. If it’s negative, it’s drifting down.</p>
</li>
<li><p><strong>Regime label</strong> is not a fancy model. It just says “compared to its own recent rolling volatility, are we currently in a high, medium, or low vol phase”.</p>
</li>
</ul>
<p>The main point is this: these numbers are deterministic. If the assistant says “max drawdown was -13%”, you can trace it back to the exact adjusted close series that produced it.</p>
<p>Next, we’ll handle the watchlist side. That means aligning returns across tickers, computing correlation, and generating a ranked snapshot.</p>
<h3 id="heading-5-watchlist-utilities">5. Watchlist Utilities</h3>
<p>Once you have more than one ticker, you want two extra things:</p>
<ul>
<li><p>a quick ranking so you can say “this is the riskiest name in the basket”</p>
</li>
<li><p>a correlation snapshot so you can see what’s moving together</p>
</li>
</ul>
<p>The only “gotcha” with correlation is dates. If TSLA has 41 price points and NVDA has 39 because of missing days, you can’t just correlate blindly. You need the returns lined up on the same dates first. That’s what <code>align_returns()</code> does.</p>
<p>Here’s the code:</p>
<pre><code class="language-python">def align_returns(price_frames):
    if not price_frames:
        return pd.DataFrame()

    parts = []
    for df in price_frames:
        if df is None or df.empty:
            continue
        x = df.copy()
        x["date"] = pd.to_datetime(x["date"], errors="coerce")
        x = x.dropna(subset=["date"])
        x["price"] = pd.to_numeric(x["price"], errors="coerce")
        x = x.dropna(subset=["price"])
        x = x.sort_values("date")
        x["ret"] = x["price"].pct_change()
        x = x.dropna(subset=["ret"])
        parts.append(x[["date", "ticker", "ret"]])

    if not parts:
        return pd.DataFrame()

    allr = pd.concat(parts, ignore_index=True)
    wide = allr.pivot(index="date", columns="ticker", values="ret").dropna(how="any")
    return wide


def corr_summary(ret_wide, base_ticker, top_n=3):
    if ret_wide is None or ret_wide.empty:
        return []

    if base_ticker not in ret_wide.columns:
        return []

    c = ret_wide.corr()[base_ticker].dropna()
    c = c.drop(labels=[base_ticker], errors="ignore")
    if c.empty:
        return []

    out = []
    for k, v in c.sort_values(ascending=False).head(top_n).items():
        out.append({"ticker": k, "corr": float(v)})

    return out


def rank_watchlist(metrics_by_ticker):
    rows = []
    for t, m in metrics_by_ticker.items():
        if not m:
            continue
        rows.append({
            "ticker": t,
            "vol_annualized": m.get("vol_annualized"),
            "max_drawdown": m.get("max_drawdown"),
            "ret_total": m.get("ret_total"),
            "trend_slope": m.get("trend_slope"),
        })

    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    df = df.sort_values(["vol_annualized", "max_drawdown"], ascending=[False, True])
    return df.reset_index(drop=True)
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>align_returns()</code> takes a list of price DataFrames, computes daily returns for each, then pivots them into a wide table like: <code>date -&gt; TSLA.US, NVDA.US, AMZN.US</code>.</p>
</li>
<li><p>We drop rows where any ticker is missing, because correlation only makes sense when the returns are aligned on the same dates.</p>
</li>
<li><p><code>corr_summary()</code> is a compact “who moves with whom” helper. We pick one base ticker, compute correlations against everything else, then grab the top few. For a watchlist widget, that’s usually enough.</p>
</li>
<li><p><code>rank_watchlist()</code> is the ranking logic for the snapshot. We sort primarily by annualized volatility, and use drawdown as a secondary risk indicator. You could choose different ranking logic. The point is to keep it deterministic and explainable.</p>
</li>
</ul>
<p>Next, we’ll build the facts objects and narration layer. That’s where we enforce the “model is just a narrator” contract.</p>
<h3 id="heading-6-facts-object-and-narration">6. Facts Object and Narration</h3>
<p>This is where the “narrator pattern” becomes real.</p>
<p>Up to this point, we’ve done everything with MCP and Python. We fetched prices and fundamentals from EODHD, we computed metrics, and we aligned returns. Now we need one clean object that represents “the truth” for this request.</p>
<p>That’s what the <code>facts</code> object is.</p>
<p>The rule is simple.</p>
<ul>
<li><p><code>facts</code> contains only things we actually fetched or computed.</p>
</li>
<li><p>The model never sees raw market data. It sees the cleaned facts.</p>
</li>
<li><p>The model is told to write using only those facts, and not to invent any numbers.</p>
</li>
</ul>
<p>Here are the functions that build those facts objects for the two demos, plus the narration function.</p>
<pre><code class="language-python">def build_facts_single(ticker, lookback_days, metrics, regime, fundamentals):
    # keep this compact. LLM will narrate from this later
    out = {
        "type": "single_ticker_brief",
        "ticker": ticker,
        "lookback_days": int(lookback_days),
        "metrics": metrics,
        "regime": regime,
    }

    if isinstance(fundamentals, dict):
        gen = fundamentals.get("General", {}) or {}
        hi = fundamentals.get("Highlights", {}) or {}
        val = fundamentals.get("Valuation", {}) or {}
        tech = fundamentals.get("Technicals", {}) or {}

        base = {
            "name": gen.get("Name"),
            "exchange": gen.get("Exchange"),
            "sector": gen.get("Sector"),
            "industry": gen.get("Industry"),
        }

        metrics = {
            "market_cap": hi.get("MarketCapitalization"),
            "pe": hi.get("PERatio") or val.get("TrailingPE") or val.get("PERatio"),
            "beta": tech.get("Beta"),
            "div_yield": hi.get("DividendYield"),
        }

        out["fundamentals"] = {k: v for k, v in {**base, **metrics}.items() if v is not None}

    return out


def build_facts_watchlist(tickers, lookback_days, rank_df, corr_bits, metrics_by_ticker):
    out = {
        "type": "watchlist_snapshot",
        "tickers": tickers,
        "lookback_days": int(lookback_days),
        "ranking": rank_df.to_dict(orient="records") if isinstance(rank_df, pd.DataFrame) else [],
        "correlation": corr_bits,
        "metrics_by_ticker": metrics_by_ticker,
    }
    return out


def narrate(facts):
    prompt = (
        "Write a short, product-ready market note using ONLY the facts below.\n"
        "No guessing. No extra numbers. If something is missing, say it's missing.\n"
        "Keep it tight and readable.\n\n"
        f"FACTS:\n{json.dumps(facts, indent=2, default=str)}"
    )

    r = oa.responses.create(
        model=NARRATION_MODEL,
        input=[{"role": "user", "content": prompt}],
    )

    try:
        return r.output_text
    except Exception:
        return str(r)
</code></pre>
<p>What’s happening here:</p>
<ul>
<li><p><code>build_facts_single()</code> takes the ticker, window, computed metrics, the vol regime label, and the fundamentals payload. But it doesn’t dump the entire fundamentals JSON. It picks a handful of fields from the <code>General</code> section and only keeps what exists. That keeps the prompt tight and the output predictable.</p>
</li>
<li><p><code>build_facts_watchlist()</code> is the same idea but for multiple tickers. It passes the ranking table, correlation notes, and per-ticker metrics.</p>
</li>
<li><p><code>narrate()</code> is basically “convert this facts object into human-friendly text”. The prompt is strict on purpose. If the model can only see these facts, it cannot hallucinate numbers outside them.</p>
</li>
</ul>
<p>One small implementation detail: <code>narrate()</code> is a normal blocking function, while everything else is async. That’s why later, inside <code>run_assistant()</code>, we call it with <code>await asyncio.to_thread(...)</code> so it doesn’t block the async flow.</p>
<h3 id="heading-7-the-orchestration-function-runassistant">7. The Orchestration Function (<code>run_assistant()</code>)</h3>
<p>This is the piece that ties everything together. It does four things in order:</p>
<ol>
<li><p>create a trace id and log the request</p>
</li>
<li><p>parse tickers and lookback, then clamp them to budgets</p>
</li>
<li><p>fetch EODHD data via MCP and compute metrics in Python</p>
</li>
<li><p>call the model to narrate the facts, then return a structured response</p>
</li>
</ol>
<p>Here’s the function:</p>
<pre><code class="language-python">def _dates_from_lookback(lookback_days):
    end = date.today()
    start = end - timedelta(days=int(lookback_days))
    return start.isoformat(), end.isoformat()

async def run_assistant(user_text, mode="auto"):
    trace_id = uuid.uuid4().hex[:10]
    log_event("request_started", trace_id, text=user_text, mode=mode)

    tickers, lookback = parse_request(user_text)
    tickers, lookback = enforce_budgets(tickers, lookback)

    if not tickers:
        return {
            "answer": "no tickers found in request",
            "metrics": {},
            "data_used": {},
            "tool_trace_id": trace_id,
        }

    log_event("parsed", trace_id, tickers=tickers, lookback_days=lookback)
    
    start_date, end_date = _dates_from_lookback(lookback)
    state = new_state()
        
    if mode == "auto":
        mode = "watchlist" if len(tickers) &gt; 1 else "single"

    try:
        if mode == "single":
            t = tickers[0]
            t_full = t if "." in t else f"{t}.US"

            log_event("tool_phase", trace_id, mode="single", ticker=t_full, start_date=start_date, end_date=end_date)

            prices = await fetch_prices(t_full, start_date, end_date, trace_id, state)
            metrics = compute_metrics(prices)
            regime = compute_regime(prices)

            fundamentals = await fetch_fundamentals(t_full, trace_id, state)

            facts = build_facts_single(t_full, lookback, metrics, regime, fundamentals)
            answer = await asyncio.to_thread(narrate, facts)

            resp = {
                "answer": answer,
                "metrics": metrics,
                "data_used": {
                    "tickers": [t_full],
                    "date_range": [start_date, end_date],
                    "tools_called": [x.get("tool") for x in state["tool_trace"]],
                    "tool_calls": state["tool_calls"],
                },
                "tool_trace_id": trace_id,
            }

            log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
            return resp

        # watchlist
        full = [x if "." in x else f"{x}.US" for x in tickers]

        log_event("tool_phase", trace_id, mode="watchlist", tickers=full, start_date=start_date, end_date=end_date)

        frames = []
        metrics_by = {}

        for t in full:
            prices = await fetch_prices(t, start_date, end_date, trace_id, state)
            frames.append(prices)
            metrics_by[t] = compute_metrics(prices)

        ret_wide = align_returns(frames)

        base = full[0]
        corr_bits = []
        top = corr_summary(ret_wide, base, top_n=3)
        if top:
            corr_bits.append({"base": base, "top": top})

        rank_df = rank_watchlist(metrics_by)
        facts = build_facts_watchlist(full, lookback, rank_df, corr_bits, metrics_by)
        answer = await asyncio.to_thread(narrate, facts)

        resp = {
            "answer": answer,
            "metrics": {"by_ticker": metrics_by},
            "data_used": {
                "tickers": full,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }

        log_event("request_finished", trace_id, tool_calls=state["tool_calls"])
        return resp

    except Exception as e:
        detail = repr(e)
        if hasattr(e, "exceptions"):
            detail = detail + " | " + " ; ".join([repr(x) for x in e.exceptions])

        log_event("request_failed", trace_id, err=detail)
        
        return {
            "answer": f"failed: {e}",
            "metrics": {},
            "data_used": {
                "tickers": tickers,
                "date_range": [start_date, end_date],
                "tools_called": [x.get("tool") for x in state["tool_trace"]],
                "tool_calls": state["tool_calls"],
            },
            "tool_trace_id": trace_id,
        }
</code></pre>
<p>This function is the glue. It creates a <code>trace_id</code>, logs the request, extracts tickers and a lookback window, then clamps both to your budgets so the assistant can’t over-fetch or spam tool calls.</p>
<p>After that, it turns the lookback into a <code>start_date</code> and <code>end_date</code>, initializes a fresh <code>state</code>, and picks a mode. In <code>single</code> mode, it fetches prices and fundamentals for one ticker via EODHD’s MCP tools, computes the metrics in Python, packs everything into a facts object, and asks the LLM to only narrate those facts. In <code>watchlist</code> mode it does the same across multiple tickers, then aligns returns so correlation is computed on matching dates, and builds a ranked snapshot.</p>
<p>The response is always structured the same way. You get the narrative <code>answer</code>, the raw computed <code>metrics</code>, a <code>data_used</code> block that shows tickers, date range, and tools called, plus a <code>tool_trace_id</code> so you can trace any output back to logs.</p>
<p>That structure is the difference between “a chat response” and “a shippable assistant output”. You can plug the same response into a UI card, a Slack alert, or a dashboard without changing anything.</p>
<h2 id="heading-demo-1-market-brief-for-one-ticker">Demo 1: Market Brief for One&nbsp;Ticker</h2>
<p>Let’s start with the simplest flow. One ticker, one lookback window, and a market brief that looks like something you could show inside a product.</p>
<p><strong>Prompt used:</strong></p>
<blockquote>
<p><em>“Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights.”</em></p>
</blockquote>
<p><strong>Code (Jupyter Notebook):</strong></p>
<pre><code class="language-python">import asyncio
import json
from core import run_assistant

q1 = "Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights."

r1 = await run_assistant(q1, mode="single")
print(json.dumps(r1, indent=2, ensure_ascii=False))
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">{"event": "request_started", "trace_id": "2af550173f", "ts": 1772735388.777, "text": "Give me a 30-day brief for AAPL. trend, volatility, max drawdown, plus 3 fundamental highlights.", "mode": "single"}
{"event": "parsed", "trace_id": "2af550173f", "ts": 1772735388.778, "tickers": ["AAPL"], "lookback_days": 30}
{"event": "tool_phase", "trace_id": "2af550173f", "ts": 1772735388.778, "mode": "single", "ticker": "AAPL.US", "start_date": "2026-02-03", "end_date": "2026-03-05"}
{"event": "request_finished", "trace_id": "2af550173f", "ts": 1772735404.392, "tool_calls": 2}
{
  "answer": "Apple Inc (AAPL.US) | NASDAQ | Technology — Consumer Electronics\n
\nOver the past 30 days, Apple shares declined 2.58%, falling from 269.48 to 
262.52 across 21 trading observations. The trend slope over the period was 
negative (-0.00175), indicating a modest downward drift.\n\nRealized daily 
volatility was 1.93%, equivalent to about 30.65% annualized. The stock is currently 
classified in a high‑volatility regime based on a 20‑day rolling volatility measure.
\n\nMaximum drawdown during the period reached -8.03%.\n\nAdditional fundamentals 
or valuation metrics were not provided.",
  "metrics": {
    "vol_daily": 0.01930981768788001,
    "vol_annualized": 0.3065338527847606,
    "ret_total": -0.02582751966750796,
    "max_drawdown": -0.08032503955127279,
    "trend_slope": -0.0017498633497641184,
    "n_points": 21,
    "start_close": 269.48,
    "end_close": 262.52
  },
  "data_used": {
    "tickers": [
      "AAPL.US"
    ],
    "date_range": [
      "2026-02-03",
      "2026-03-05"
    ],
    "tools_called": [
      "get_historical_stock_prices",
      "get_fundamentals_data"
    ],
    "tool_calls": 2
  },
  "tool_trace_id": "2af550173f"
}
</code></pre>
<p>First, you’ll see the log events. They’re not part of the final response. They’re just the trace trail.</p>
<ul>
<li><p><code>request_started</code> shows the raw prompt and that we forced <code>mode="single"</code>.</p>
</li>
<li><p><code>parsed</code> confirms the parser extracted <code>AAPL</code> and a 30-day lookback.</p>
</li>
<li><p><code>tool_phase</code> shows what we actually fetched: <code>AAPL.US</code> from <code>2026-02-03</code> to <code>2026-03-05</code>.</p>
</li>
<li><p><code>request_finished</code> confirms we made exactly <strong>2 tool calls.</strong></p>
</li>
</ul>
<p>Now the actual response JSON:</p>
<p><code>answer</code> is the narrative. In this run it summarizes:</p>
<ul>
<li><p>return of -2.58% (269.48 to 262.52)</p>
</li>
<li><p>21 price observations in that window</p>
</li>
<li><p>negative trend slope (-0.00175) meaning mild downward drift</p>
</li>
<li><p>daily vol 1.93% and annualized vol 30.65%</p>
</li>
<li><p>max drawdown -8.03%</p>
</li>
<li><p>and it labels the regime as high volatility using the rolling vol logic.</p>
</li>
</ul>
<p><code>metrics</code> is where those numbers come from. This is the deterministic part. <code>ret_total</code>, <code>vol_daily</code>, <code>vol_annualized</code>, <code>max_drawdown</code>, and <code>trend_slope</code> were computed directly from the fetched closes. <code>start_close</code>, <code>end_close</code>, and <code>n_points</code> explain the exact series used.</p>
<p><code>data_used</code> is the audit block for this specific output. It shows:</p>
<ul>
<li><p>ticker normalized to <code>AAPL.US</code></p>
</li>
<li><p>the exact date range pulled</p>
</li>
<li><p>the exact tools called on the MCP server: <code>get_historical_stock_prices</code> and <code>get_fundamentals_data</code></p>
</li>
<li><p>and again, <code>tool_calls: 2</code> so you can quickly spot runaway calls.</p>
</li>
</ul>
<p><code>tool_trace_id</code> (<code>2af550173f</code>) is your handle for debugging. Every log line above carries the same id, so you can trace this brief back to the exact tool calls and parameters.</p>
<h2 id="heading-demo-2-watchlist-snapshot">Demo 2: Watchlist Snapshot</h2>
<p>Now let’s switch to the watchlist flow. Same assistant core. The only difference is we pass multiple tickers and a longer window, so the output becomes a comparative risk snapshot.</p>
<p><strong>Prompt used:</strong></p>
<blockquote>
<p><em>“Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag valuation risks.”</em></p>
</blockquote>
<p><strong>Code:</strong></p>
<pre><code class="language-python">q2 = "Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag risk outliers."

r2 = await run_assistant(q2, mode="watchlist")
print(json.dumps(r2, indent=2, ensure_ascii=False))
</code></pre>
<p><strong>Output:</strong></p>
<pre><code class="language-plaintext">{"event": "request_started", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "text": "Compare TSLA, NVDA, AMZN over the last 60 days. rank by volatility and drawdown, and flag valuation risks.", "mode": "watchlist"}
{"event": "parsed", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "tickers": ["TSLA", "NVDA", "AMZN"], "lookback_days": 60}
{"event": "tool_phase", "trace_id": "1b67bb47d6", "ts": 1772735404.394, "mode": "watchlist", "tickers": ["TSLA.US", "NVDA.US", "AMZN.US"], "start_date": "2026-01-05", "end_date": "2026-03-06"}
{"event": "request_finished", "trace_id": "1b67bb47d6", "ts": 1772735423.004, "tool_calls": 3}
{
  "answer": "Market Watchlist Snapshot (last 60 days)\n\nAll three names show 
negative total returns and downward trend slopes over the period.\n\nNVDA.US 
ranks highest in the group despite a small decline. Total return is -0.027. 
Price moved from 188.12 to 183.04 across 41 observations. Annualized volatility is 
0.3808 and maximum drawdown is -0.107.\n\nTSLA.US shows the second‑highest volatility 
profile with annualized volatility of 0.3561. Total return is -0.101, with price 
falling from 451.67 to 405.94. Maximum drawdown reached -0.131. Trend slope is negative.
\n\nAMZN.US has the lowest volatility in the set (annualized 0.3196) but the deepest 
drawdown at -0.196. Total return is -0.0697, with price moving from 233.06 to 
216.82. Trend slope is also negative.\n\nCorrelation: TSLA shows a stronger 
relationship with NVDA (0.533) than with AMZN (0.177).\n\nMissing from the 
data: trading volume, catalysts, sector context, and forward-looking indicators.",
  "metrics": {
    "by_ticker": {
      "TSLA.US": {
        "vol_daily": 0.02243518393199404,
        "vol_annualized": 0.3561475038122908,
        "ret_total": -0.10124648526579139,
        "max_drawdown": -0.13115770363318358,
        "trend_slope": -0.0026452119688441023,
        "n_points": 41,
        "start_close": 451.67,
        "end_close": 405.94
      },
      "NVDA.US": {
        "vol_daily": 0.023987861378298222,
        "vol_annualized": 0.3807954941476091,
        "ret_total": -0.027004039974484417,
        "max_drawdown": -0.10716326424601319,
        "trend_slope": -4.3573704505466623e-05,
        "n_points": 41,
        "start_close": 188.12,
        "end_close": 183.04
      },
      "AMZN.US": {
        "vol_daily": 0.020129905817481322,
        "vol_annualized": 0.31955234824924766,
        "ret_total": -0.06968162704882863,
        "max_drawdown": -0.1964184655186353,
        "trend_slope": -0.00520436173926906,
        "n_points": 41,
        "start_close": 233.06,
        "end_close": 216.82
      }
    }
  },
  "data_used": {
    "tickers": [
      "TSLA.US",
      "NVDA.US",
      "AMZN.US"
    ],
    "date_range": [
      "2026-01-05",
      "2026-03-06"
    ],
    "tools_called": [
      "get_historical_stock_prices",
      "get_historical_stock_prices",
      "get_historical_stock_prices"
    ],
    "tool_calls": 3
  },
  "tool_trace_id": "1b67bb47d6"
}
</code></pre>
<p>The logs show the assistant correctly extracted <code>TSLA</code>, <code>NVDA</code>, <code>AMZN</code> and a <strong>60-day</strong> lookback, then fetched <code>TSLA.US</code>, <code>NVDA.US</code>, and <code>AMZN.US</code> from <code>2026-01-05</code> to <code>2026-03-06</code>. Since this is a watchlist request, it made exactly <strong>3</strong> tool calls. One <code>get_historical_stock_prices</code> call per ticker.</p>
<p>Inside <code>answer</code>, the model is basically summarizing what Python computed. In this run, all three names had negative returns and negative trend slopes.</p>
<ul>
<li><p>NVDA had the highest annualized volatility at 0.3808 with a relatively small decline of -2.7%.</p>
</li>
<li><p>TSLA was next in volatility (0.3561) with a larger decline (-10.1%) and drawdown of about -13.1%.</p>
</li>
<li><p>AMZN had the lowest volatility (0.3196) but the deepest drawdown at around -19.6%. It also includes a correlation note derived from the aligned returns table.</p>
</li>
<li><p>TSLA’s return series correlated more with NVDA (0.533) than with AMZN (0.177) in this window.</p>
</li>
</ul>
<p><code>metrics.by_ticker</code> is where the snapshot really lives. It contains the full computed metric set per ticker, including observation count (<code>n_points=41</code>) and the start and end closes used for the return calculation. <code>data_used</code> shows exactly what we fetched, including the tickers, the date range, and the three price tool calls. And <code>tool_trace_id</code> is the id that links this output back to the full trace logs.</p>
<p>So how would a product team use this? Well, this output is already shaped like a widget backend. You can render the ranking as a watchlist “risk card”, show the top volatility and drawdown names, and drop the narrative into a compact summary box. Since you also get deterministic <code>metrics</code>, you can build UI elements without parsing text, and still keep the narration as a layer on top.</p>
<h2 id="heading-what-makes-this-shippable-and-what-can-be-improved">What Makes this Shippable, and What Can Be&nbsp;Improved?</h2>
<p>The core reason this works in a real product setting is that the numbers are deterministic. Prices and fundamentals come from EODHD via MCP, metrics are computed in Python, and the model only writes narrative from a facts object.</p>
<p>On top of that, every run is traceable. You get tool logs, <code>data_used</code>, and a <code>tool_trace_id</code>, plus hard limits on lookback, tickers, and tool calls so the system can’t spiral.</p>
<p>At the same time, this is still an MVP. The parsing is a simple heuristic, the metric set is intentionally small, and fundamentals are only lightly extracted.</p>
<p>If you want to take this further, the next upgrades are straightforward: you can add volume and a couple more data tools like earnings calendar and news, introduce caching for repeated requests, build a tiny evaluation harness with fixed prompts and expected outputs, then wrap <code>run_assistant()</code> behind a small API so it can power an actual UI or internal service.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The main takeaway is simple. If you want a financial assistant to be usable beyond casual chat, you need to separate facts from narrative. The MCP protocol gives you a clean way to connect to tool providers via an MCP server. Python gives you deterministic metrics, and the model becomes the last-mile layer that turns those facts into readable output.</p>
<p>This is still a small build, but it’s already shaped like something you can ship. The response format is structured, traceable, and easy to plug into a UI. If you extend it with a few more tools and add basic caching, it can quickly move from a Jupyter notebook demo to a real feature.</p>
<p>If you want to try the same approach with a full market data tool layer out of the box, EODHD’s MCP server is a solid starting point.</p>
<p>With that being said, you’ve reached the end of the article. Hope you learned something new and useful today. Thank you very much for your time.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Docker Container Doctor: How I Built an AI Agent That Monitors and Fixes My Containers ]]>
                </title>
                <description>
                    <![CDATA[ Maybe this sounds familiar: your production container crashes at 3 AM. By the time you wake up, it's been throwing the same error for 2 hours. You SSH in, pull logs, decode the cryptic stack trace, Go ]]>
                </description>
                <link>https://www.freecodecamp.org/news/docker-container-doctor-how-i-built-an-ai-agent-that-monitors-and-fixes-my-containers/</link>
                <guid isPermaLink="false">69c1768730a9b81e3a833f20</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agentic AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agents ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Balajee Asish Brahmandam ]]>
                </dc:creator>
                <pubDate>Mon, 23 Mar 2026 17:21:11 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8bb7701d-e519-407f-92ba-59639e13729d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Maybe this sounds familiar: your production container crashes at 3 AM. By the time you wake up, it's been throwing the same error for 2 hours. You SSH in, pull logs, decode the cryptic stack trace, Google the error, and finally restart it. Twenty minutes of your morning gone. And the worst part? It happens again next week.</p>
<p>I got tired of this cycle. I was running 5 containerized services on a single Linode box – a Flask API, a Postgres database, an Nginx reverse proxy, a Redis cache, and a background worker. Every other week, one of them would crash. The logs were messy. The errors weren't obvious. And I'd waste time debugging something that could've been auto-detected and fixed in seconds.</p>
<p>So I built something better: a Python agent that watches your containers in real-time, spots errors, figures out what went wrong using Claude, and fixes them without waking you up. I call it the Container Doctor. It's not magic. It's Docker API + LLM reasoning + some automation glue. Here's exactly how I built it, what went wrong along the way, and what I'd do differently.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-why-not-just-use-prometheus">Why Not Just Use Prometheus?</a></p>
</li>
<li><p><a href="#heading-the-architecture">The Architecture</a></p>
</li>
<li><p><a href="#heading-setting-up-the-project">Setting Up the Project</a></p>
</li>
<li><p><a href="#heading-the-monitoring-script--line-by-line">The Monitoring Script — Line by Line</a></p>
</li>
<li><p><a href="#heading-the-claude-diagnosis-prompt-and-why-structure-matters">The Claude Diagnosis Prompt (and Why Structure Matters)</a></p>
</li>
<li><p><a href="#heading-auto-fix-logic--being-conservative-on-purpose">Auto-Fix Logic — Being Conservative on Purpose</a></p>
</li>
<li><p><a href="#heading-adding-slack-notifications">Adding Slack Notifications</a></p>
</li>
<li><p><a href="#heading-health-check-endpoint">Health Check Endpoint</a></p>
</li>
<li><p><a href="#heading-rate-limiting-claude-calls">Rate Limiting Claude Calls</a></p>
</li>
<li><p><a href="#heading-docker-compose--the-full-setup">Docker Compose — The Full Setup</a></p>
</li>
<li><p><a href="#heading-real-errors-i-caught-in-production">Real Errors I Caught in Production</a></p>
</li>
<li><p><a href="#heading-cost-breakdown--what-this-actually-costs">Cost Breakdown — What This Actually Costs</a></p>
</li>
<li><p><a href="#heading-security-considerations">Security Considerations</a></p>
</li>
<li><p><a href="#heading-what-id-do-differently">What I'd Do Differently</a></p>
</li>
<li><p><a href="#heading-whats-next">What's Next?</a></p>
</li>
</ol>
<h2 id="heading-why-not-just-use-prometheus">Why Not Just Use Prometheus?</h2>
<p>Fair question. Prometheus, Grafana, DataDog – they're all great. But for my setup, they were overkill. I had 5 containers on a $20/month Linode. Setting up Prometheus means deploying a metrics server, configuring exporters for each service, building Grafana dashboards, and writing alert rules. That's a whole side project just to monitor a side project.</p>
<p>Even then, those tools tell you <em>what</em> happened. They'll show you a spike in memory or a 500 error rate. But they won't tell you <em>why</em>. You still need a human to look at the logs, figure out the root cause, and decide what to do.</p>
<p>That's the gap I wanted to fill. I didn't need another dashboard. I needed something that could read a stack trace, understand the context, and either fix it or tell me exactly what to do when I wake up. Claude turned out to be surprisingly good at this. It can read a Python traceback and tell you the issue faster than most junior devs (and some senior ones, honestly).</p>
<h2 id="heading-the-architecture">The Architecture</h2>
<p>Here's how the pieces fit together:</p>
<pre><code class="language-plaintext">┌─────────────────────────────────────────────┐
│              Docker Host                      │
│                                               │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐   │
│  │   web    │  │   api    │  │    db    │   │
│  │ (nginx)  │  │ (flask)  │  │(postgres)│   │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘   │
│       │              │              │         │
│       └──────────────┼──────────────┘         │
│                      │                         │
│              Docker Socket                     │
│                      │                         │
│            ┌─────────┴─────────┐              │
│            │ Container Doctor  │              │
│            │  (Python agent)   │              │
│            └─────────┬─────────┘              │
│                      │                         │
└──────────────────────┼─────────────────────────┘
                       │
              ┌────────┴────────┐
              │   Claude API    │
              │  (diagnosis)    │
              └────────┬────────┘
                       │
              ┌────────┴────────┐
              │  Slack Webhook  │
              │  (alerts)       │
              └─────────────────┘
</code></pre>
<p>The flow works like this:</p>
<ol>
<li><p>The Container Doctor runs in its own container with the Docker socket mounted</p>
</li>
<li><p>Every 10 seconds, it pulls the last 50 lines of logs from each target container</p>
</li>
<li><p>It scans for error patterns (keywords like "error", "exception", "traceback", "fatal")</p>
</li>
<li><p>When it finds something, it sends the logs to Claude with a structured prompt</p>
</li>
<li><p>Claude returns a JSON diagnosis: root cause, severity, suggested fix, and whether it's safe to auto-restart</p>
</li>
<li><p>If severity is high and auto-restart is safe, the script restarts the container</p>
</li>
<li><p>Either way, it sends a Slack notification with the full diagnosis</p>
</li>
<li><p>A simple health endpoint lets you check the doctor's own status</p>
</li>
</ol>
<p>The key insight: the script doesn't try to be smart about the diagnosis itself. It outsources all the thinking to Claude. The script's job is just plumbing: collecting logs, routing them to Claude, and executing the response.</p>
<h2 id="heading-setting-up-the-project">Setting Up the Project</h2>
<p>Create your project directory:</p>
<pre><code class="language-bash">mkdir container-doctor &amp;&amp; cd container-doctor
</code></pre>
<p>Here's your <code>requirements.txt</code>:</p>
<pre><code class="language-plaintext">docker==7.0.0
anthropic&gt;=0.28.0
python-dotenv==1.0.0
flask==3.0.0
requests==2.31.0
</code></pre>
<p>Install locally for testing: <code>pip install -r requirements.txt</code></p>
<p>Create a <code>.env</code> file:</p>
<pre><code class="language-bash">ANTHROPIC_API_KEY=sk-ant-...
TARGET_CONTAINERS=web,api,db
CHECK_INTERVAL=10
LOG_LINES=50
AUTO_FIX=true
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL
POSTGRES_USER=user
POSTGRES_PASSWORD=changeme
POSTGRES_DB=mydb
MAX_DIAGNOSES_PER_HOUR=20
</code></pre>
<p>A quick note on <code>CHECK_INTERVAL</code>: 10 seconds is aggressive. For production, I'd bump this to 30-60 seconds. I kept it low during development so I could see results faster, and honestly forgot to change it. My API bill reminded me.</p>
<h2 id="heading-the-monitoring-script-line-by-line">The Monitoring Script – Line by Line</h2>
<p>Here's the full <code>container_doctor.py</code>. I'll walk through the important parts after:</p>
<pre><code class="language-python">import docker
import json
import time
import logging
import os
import requests
from datetime import datetime, timedelta
from collections import defaultdict
from threading import Thread
from flask import Flask, jsonify
from anthropic import Anthropic

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

client = Anthropic()
docker_client = None

# --- Config ---
TARGET_CONTAINERS = os.getenv("TARGET_CONTAINERS", "").split(",")
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "10"))
LOG_LINES = int(os.getenv("LOG_LINES", "50"))
AUTO_FIX = os.getenv("AUTO_FIX", "true").lower() == "true"
SLACK_WEBHOOK = os.getenv("SLACK_WEBHOOK_URL", "")
MAX_DIAGNOSES = int(os.getenv("MAX_DIAGNOSES_PER_HOUR", "20"))

# --- State tracking ---
diagnosis_history = []
fix_history = defaultdict(list)
last_error_seen = {}
rate_limit_counter = defaultdict(int)
rate_limit_reset = datetime.now() + timedelta(hours=1)

app = Flask(__name__)


def get_docker_client():
    """Lazily initialize Docker client."""
    global docker_client
    if docker_client is None:
        docker_client = docker.from_env()
    return docker_client


def get_container_logs(container_name):
    """Fetch last N lines from a container."""
    try:
        container = get_docker_client().containers.get(container_name)
        logs = container.logs(
            tail=LOG_LINES,
            timestamps=True
        ).decode("utf-8")
        return logs
    except docker.errors.NotFound:
        logger.warning(f"Container '{container_name}' not found. Skipping.")
        return None
    except docker.errors.APIError as e:
        logger.error(f"Docker API error for {container_name}: {e}")
        return None
    except Exception as e:
        logger.error(f"Unexpected error fetching logs for {container_name}: {e}")
        return None


def detect_errors(logs):
    """Check if logs contain error patterns."""
    error_patterns = [
        "error", "exception", "traceback", "failed", "crash",
        "fatal", "panic", "segmentation fault", "out of memory",
        "killed", "oomkiller", "connection refused", "timeout",
        "permission denied", "no such file", "errno"
    ]
    logs_lower = logs.lower()
    found = []
    for pattern in error_patterns:
        if pattern in logs_lower:
            found.append(pattern)
    return found


def is_new_error(container_name, logs):
    """Check if this is a new error or the same one we already diagnosed."""
    log_hash = hash(logs[-200:])  # Hash last 200 chars
    if last_error_seen.get(container_name) == log_hash:
        return False
    last_error_seen[container_name] = log_hash
    return True


def check_rate_limit():
    """Ensure we don't spam Claude with too many requests."""
    global rate_limit_counter, rate_limit_reset

    now = datetime.now()
    if now &gt; rate_limit_reset:
        rate_limit_counter.clear()
        rate_limit_reset = now + timedelta(hours=1)

    total = sum(rate_limit_counter.values())
    if total &gt;= MAX_DIAGNOSES:
        logger.warning(f"Rate limit reached ({total}/{MAX_DIAGNOSES} per hour). Skipping diagnosis.")
        return False
    return True


def diagnose_with_claude(container_name, logs, error_patterns):
    """Send logs to Claude for diagnosis."""
    if not check_rate_limit():
        return None

    rate_limit_counter[container_name] += 1

    prompt = f"""You are a DevOps expert analyzing container logs.

Container: {container_name}
Timestamp: {datetime.now().isoformat()}
Detected patterns: {', '.join(error_patterns)}

Recent logs:
---
{logs}
---

Analyze these logs and respond with ONLY valid JSON (no markdown, no explanation):
{{
    "root_cause": "One sentence explaining exactly what went wrong",
    "severity": "low|medium|high",
    "suggested_fix": "Step-by-step fix the operator should apply",
    "auto_restart_safe": true or false,
    "config_suggestions": ["ENV_VAR=value", "..."],
    "likely_recurring": true or false,
    "estimated_impact": "What breaks if this isn't fixed"
}}
"""

    try:
        message = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=600,
            messages=[
                {"role": "user", "content": prompt}
            ]
        )
        return message.content[0].text
    except Exception as e:
        logger.error(f"Claude API error: {e}")
        return None


def parse_diagnosis(diagnosis_text):
    """Extract JSON from Claude's response."""
    if not diagnosis_text:
        return None
    try:
        start = diagnosis_text.find("{")
        end = diagnosis_text.rfind("}") + 1
        if start &gt;= 0 and end &gt; start:
            json_str = diagnosis_text[start:end]
            return json.loads(json_str)
    except json.JSONDecodeError as e:
        logger.error(f"JSON parse error: {e}")
        logger.debug(f"Raw response: {diagnosis_text}")
    except Exception as e:
        logger.error(f"Failed to parse diagnosis: {e}")
    return None


def apply_fix(container_name, diagnosis):
    """Apply auto-fixes if safe."""
    if not AUTO_FIX:
        logger.info(f"Auto-fix disabled globally. Skipping {container_name}.")
        return False

    if not diagnosis.get("auto_restart_safe"):
        logger.info(f"Claude says restart is unsafe for {container_name}. Skipping.")
        return False

    # Don't restart the same container more than 3 times per hour
    recent_fixes = [
        t for t in fix_history[container_name]
        if t &gt; datetime.now() - timedelta(hours=1)
    ]
    if len(recent_fixes) &gt;= 3:
        logger.warning(
            f"Container {container_name} already restarted {len(recent_fixes)} "
            f"times this hour. Something deeper is wrong. Skipping."
        )
        send_slack_alert(
            container_name, diagnosis,
            extra="REPEATED FAILURE: This container has been restarted 3+ times "
                  "in the last hour. Manual intervention needed."
        )
        return False

    try:
        container = get_docker_client().containers.get(container_name)
        logger.info(f"Restarting container {container_name}...")
        container.restart(timeout=30)
        fix_history[container_name].append(datetime.now())
        logger.info(f"Container {container_name} restarted successfully")

        # Verify it's actually running after restart
        time.sleep(5)
        container.reload()
        if container.status != "running":
            logger.error(f"Container {container_name} failed to start after restart")
            return False

        return True
    except Exception as e:
        logger.error(f"Failed to restart {container_name}: {e}")
        return False


def send_slack_alert(container_name, diagnosis, extra=""):
    """Send diagnosis to Slack."""
    if not SLACK_WEBHOOK:
        return

    severity_emoji = {
        "low": "🟡",
        "medium": "🟠",
        "high": "🔴"
    }

    severity = diagnosis.get("severity", "unknown")
    emoji = severity_emoji.get(severity, "⚪")

    blocks = [
        {
            "type": "header",
            "text": {
                "type": "plain_text",
                "text": f"{emoji} Container Doctor Alert: {container_name}"
            }
        },
        {
            "type": "section",
            "fields": [
                {"type": "mrkdwn", "text": f"*Severity:* {severity}"},
                {"type": "mrkdwn", "text": f"*Container:* `{container_name}`"},
                {"type": "mrkdwn", "text": f"*Root Cause:* {diagnosis.get('root_cause', 'Unknown')}"},
                {"type": "mrkdwn", "text": f"*Fix:* {diagnosis.get('suggested_fix', 'N/A')}"},
            ]
        }
    ]

    if diagnosis.get("config_suggestions"):
        suggestions = "\n".join(
            f"• `{s}`" for s in diagnosis["config_suggestions"]
        )
        blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f"*Config Suggestions:*\n{suggestions}"
            }
        })

    if extra:
        blocks.append({
            "type": "section",
            "text": {"type": "mrkdwn", "text": f"*⚠️ {extra}*"}
        })

    try:
        requests.post(SLACK_WEBHOOK, json={"blocks": blocks}, timeout=10)
    except Exception as e:
        logger.error(f"Slack notification failed: {e}")


# --- Health Check Endpoint ---
@app.route("/health")
def health():
    """Health check endpoint for the doctor itself."""
    try:
        get_docker_client().ping()
        docker_ok = True
    except:
        docker_ok = False

    return jsonify({
        "status": "healthy" if docker_ok else "degraded",
        "docker_connected": docker_ok,
        "monitoring": TARGET_CONTAINERS,
        "total_diagnoses": len(diagnosis_history),
        "fixes_applied": {k: len(v) for k, v in fix_history.items()},
        "rate_limit_remaining": MAX_DIAGNOSES - sum(rate_limit_counter.values()),
        "uptime_check": datetime.now().isoformat()
    })


@app.route("/history")
def history():
    """Return recent diagnosis history."""
    return jsonify(diagnosis_history[-50:])


def monitor_containers():
    """Main monitoring loop."""
    logger.info(f"Container Doctor starting up")
    logger.info(f"Monitoring: {TARGET_CONTAINERS}")
    logger.info(f"Check interval: {CHECK_INTERVAL}s")
    logger.info(f"Auto-fix: {AUTO_FIX}")
    logger.info(f"Rate limit: {MAX_DIAGNOSES}/hour")

    while True:
        for container_name in TARGET_CONTAINERS:
            container_name = container_name.strip()
            if not container_name:
                continue

            logs = get_container_logs(container_name)
            if not logs:
                continue

            error_patterns = detect_errors(logs)
            if not error_patterns:
                continue

            # Skip if we already diagnosed this exact error
            if not is_new_error(container_name, logs):
                continue

            logger.warning(
                f"Errors detected in {container_name}: {error_patterns}"
            )

            diagnosis_text = diagnose_with_claude(
                container_name, logs, error_patterns
            )
            if not diagnosis_text:
                continue

            diagnosis = parse_diagnosis(diagnosis_text)
            if not diagnosis:
                logger.error("Failed to parse Claude's response. Skipping.")
                continue

            # Record it
            diagnosis_history.append({
                "container": container_name,
                "timestamp": datetime.now().isoformat(),
                "diagnosis": diagnosis,
                "patterns": error_patterns
            })

            logger.info(
                f"Diagnosis for {container_name}: "
                f"severity={diagnosis.get('severity')}, "
                f"cause={diagnosis.get('root_cause')}"
            )

            # Auto-fix only on high severity
            fixed = False
            if diagnosis.get("severity") == "high":
                fixed = apply_fix(container_name, diagnosis)

            # Always notify Slack
            send_slack_alert(
                container_name, diagnosis,
                extra="Auto-restarted" if fixed else ""
            )

        time.sleep(CHECK_INTERVAL)


if __name__ == "__main__":
    # Run Flask health endpoint in background
    flask_thread = Thread(
        target=lambda: app.run(host="0.0.0.0", port=8080, debug=False),
        daemon=True
    )
    flask_thread.start()
    logger.info("Health endpoint running on :8080")

    try:
        monitor_containers()
    except KeyboardInterrupt:
        logger.info("Container Doctor shutting down")
</code></pre>
<p>That's a lot of code, so let me walk through the parts that matter.</p>
<p><strong>Error deduplication (</strong><code>is_new_error</code><strong>)</strong>: This was a lesson I learned the hard way. Without this, the script would see the same error every 10 seconds and spam Claude with identical requests. I hash the last 200 characters of the log output and skip if it matches the last error we saw. Simple, but it cut my API costs by about 80%.</p>
<p><strong>Rate limiting (</strong><code>check_rate_limit</code><strong>)</strong>: Belt and suspenders. Even with deduplication, I cap it at 20 diagnoses per hour. If something is so broken that it's generating 20+ unique errors per hour, you need a human anyway.</p>
<p><strong>Restart throttling (inside</strong> <code>apply_fix</code><strong>)</strong>: If the same container has been restarted 3 times in an hour, something deeper is wrong. A restart loop won't fix a misconfigured database or a missing volume. The script stops restarting and sends a louder Slack alert instead.</p>
<p><strong>Post-restart verification</strong>: After restarting, the script waits 5 seconds and checks if the container is actually running. I've seen cases where a container restarts and immediately crashes again. Without this check, the script would report success while the container is still down.</p>
<h2 id="heading-the-claude-diagnosis-prompt-and-why-structure-matters">The Claude Diagnosis Prompt (and Why Structure Matters)</h2>
<p>Getting Claude to return parseable JSON took some iteration. My first attempt used a casual prompt and I got back paragraphs of explanation with JSON buried somewhere in the middle. Sometimes it'd use markdown code fences, sometimes not.</p>
<p>The version I landed on is explicit about format:</p>
<pre><code class="language-python">prompt = f"""You are a DevOps expert analyzing container logs.

Container: {container_name}
Timestamp: {datetime.now().isoformat()}
Detected patterns: {', '.join(error_patterns)}

Recent logs:
---
{logs}
---

Analyze these logs and respond with ONLY valid JSON (no markdown, no explanation):
{{
    "root_cause": "One sentence explaining exactly what went wrong",
    "severity": "low|medium|high",
    "suggested_fix": "Step-by-step fix the operator should apply",
    "auto_restart_safe": true or false,
    "config_suggestions": ["ENV_VAR=value", "..."],
    "likely_recurring": true or false,
    "estimated_impact": "What breaks if this isn't fixed"
}}
"""
</code></pre>
<p>A few things I learned:</p>
<p><strong>Include the detected patterns.</strong> Telling Claude "I found 'timeout' and 'connection refused'" helps it focus. Without this, it sometimes fixated on irrelevant warnings in the logs.</p>
<p><strong>Ask for</strong> <code>estimated_impact</code><strong>.</strong> This field turned out to be the most useful in Slack alerts. When your team sees "Database connections will pile up and crash the API within 15 minutes," they act faster than when they see "connection pool exhausted."</p>
<p><code>likely_recurring</code> <strong>is gold.</strong> If Claude says an issue is likely to recur, I know a restart is a band-aid and I need to actually fix the root cause. I flag these in Slack with extra emphasis.</p>
<p>Claude returns something like:</p>
<pre><code class="language-json">{
    "root_cause": "Connection pool exhausted. Default pool size is 5, but app has 8+ concurrent workers.",
    "severity": "high",
    "suggested_fix": "1. Set POOL_SIZE=20 in environment. 2. Add connection timeout of 30s. 3. Consider a connection pooler like PgBouncer.",
    "auto_restart_safe": true,
    "config_suggestions": ["POOL_SIZE=20", "CONNECTION_TIMEOUT=30"],
    "likely_recurring": true,
    "estimated_impact": "API requests will queue and timeout. Users will see 503 errors within 2-3 minutes."
}
</code></pre>
<p>I only auto-restart on <code>high</code> severity. Medium and low issues get logged, sent to Slack, and I deal with them during business hours. This distinction matters: you don't want the script restarting containers over every transient warning.</p>
<h2 id="heading-auto-fix-logic-being-conservative-on-purpose">Auto-Fix Logic – Being Conservative on Purpose</h2>
<p>The auto-fix function is intentionally limited. Right now it only restarts containers. It doesn't modify environment variables, change configs, or scale services. Here's why:</p>
<p>Restarting is safe and reversible. If the restart makes things worse, the container just crashes again and I get another alert. But if the script started changing environment variables or modifying docker-compose files, a bad decision could cascade across services.</p>
<p>The three safety checks before any restart:</p>
<ol>
<li><p><strong>Global toggle</strong>: <code>AUTO_FIX=true</code> in .env. I can kill all auto-fixes instantly by changing one variable.</p>
</li>
<li><p><strong>Claude's assessment</strong>: <code>auto_restart_safe</code> must be true. If Claude says "don't restart this, it'll corrupt the database," the script listens.</p>
</li>
<li><p><strong>Restart throttle</strong>: No more than 3 restarts per container per hour. After that, it's a human problem.</p>
</li>
</ol>
<p>If I were building this for a team, I'd add approval flows. Send a Slack message with "Restart?" and two buttons. Wait for a human to click yes. That adds latency but removes the risk of automated chaos.</p>
<h2 id="heading-adding-slack-notifications">Adding Slack Notifications</h2>
<p>Every diagnosis gets sent to Slack, whether the container was restarted or not. The notification includes color-coded severity, root cause, suggested fix, and config suggestions.</p>
<p>The Slack Block Kit formatting makes these alerts scannable. A red dot for high severity, orange for medium, yellow for low. Your team can glance at the channel and know if they need to drop everything or if it can wait.</p>
<p>To set this up, create a Slack app at <a href="https://api.slack.com/apps">api.slack.com/apps</a>, add an incoming webhook, and paste the URL in your <code>.env</code>.</p>
<h2 id="heading-health-check-endpoint">Health Check Endpoint</h2>
<p>The doctor needs a doctor. I added a simple Flask endpoint so I can monitor the monitoring script:</p>
<pre><code class="language-bash">curl http://localhost:8080/health
</code></pre>
<p>Returns:</p>
<pre><code class="language-json">{
    "status": "healthy",
    "docker_connected": true,
    "monitoring": ["web", "api", "db"],
    "total_diagnoses": 14,
    "fixes_applied": {"api": 2, "web": 1},
    "rate_limit_remaining": 6,
    "uptime_check": "2026-03-15T14:30:00"
}
</code></pre>
<p>And <code>/history</code> returns the last 50 diagnoses:</p>
<pre><code class="language-bash">curl http://localhost:8080/history
</code></pre>
<p>I point an uptime checker (UptimeRobot, free tier) at the <code>/health</code> endpoint. If the Container Doctor itself goes down, I get an email. It's monitoring all the way down.</p>
<h2 id="heading-rate-limiting-claude-calls">Rate Limiting Claude Calls</h2>
<p>This is where I burned money during development. Without rate limiting, the script was sending 100+ requests per hour during a container crash loop. At a few cents per request, that's a few dollars per hour. Not catastrophic, but annoying.</p>
<p>The rate limiter is simple: a counter that resets every hour. Default cap is 20 diagnoses per hour. If you hit the limit, the script logs a warning and skips diagnosis until the window resets. Errors still get detected, they just don't get sent to Claude.</p>
<p>Combined with error deduplication (same error won't trigger a second diagnosis), this keeps my Claude bill under $5/month even with 5 containers monitored.</p>
<h2 id="heading-docker-compose-the-full-setup">Docker Compose – The Full Setup</h2>
<p>Here's the complete <code>docker-compose.yml</code> with the Container Doctor, a sample web server, API, and database:</p>
<pre><code class="language-yaml">version: '3.8'

services:
  container_doctor:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: container_doctor
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - TARGET_CONTAINERS=web,api,db
      - CHECK_INTERVAL=10
      - LOG_LINES=50
      - AUTO_FIX=true
      - SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL}
      - MAX_DIAGNOSES_PER_HOUR=20
    ports:
      - "8080:8080"
    restart: unless-stopped
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  web:
    image: nginx:latest
    container_name: web
    ports:
      - "80:80"
    restart: unless-stopped

  api:
    build: ./api
    container_name: api
    environment:
      - DATABASE_URL=postgres://\({POSTGRES_USER}:\){POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      - POOL_SIZE=20
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: postgres:15
    container_name: db
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
    volumes:
      - db_data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:
</code></pre>
<p>And the <code>Dockerfile</code>:</p>
<pre><code class="language-dockerfile">FROM python:3.12-slim

WORKDIR /app

RUN apt-get update &amp;&amp; apt-get install -y curl &amp;&amp; rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY container_doctor.py .

EXPOSE 8080

CMD ["python", "-u", "container_doctor.py"]
</code></pre>
<p>Start everything: <code>docker compose up -d</code></p>
<p><strong>Important:</strong> The socket mount (<code>/var/run/docker.sock:/var/run/docker.sock</code>) gives the Container Doctor full access to the Docker daemon. Don't copy <code>.env</code> into the Docker image either — it bakes your API key into the image layer. Pass environment variables via the compose file or at runtime.</p>
<h2 id="heading-real-errors-i-caught-in-production">Real Errors I Caught in Production</h2>
<p>I've been running this for about 3 weeks now. Here are the actual incidents it caught:</p>
<h3 id="heading-incident-1-oom-kill-week-1">Incident 1: OOM Kill (Week 1)</h3>
<p>Logs showed a single word: <code>Killed</code>. That's Linux's OOMKiller doing its thing.</p>
<p>Claude's diagnosis:</p>
<pre><code class="language-json">{
    "root_cause": "Process killed by OOMKiller. Container is requesting more memory than the 256MB limit allows under load.",
    "severity": "high",
    "suggested_fix": "Increase memory limit to 512MB in docker-compose. Monitor if the leak continues at higher limits.",
    "auto_restart_safe": true,
    "config_suggestions": ["mem_limit: 512m", "memswap_limit: 1g"],
    "likely_recurring": true,
    "estimated_impact": "API is completely down. All requests return 502 from nginx."
}
</code></pre>
<p>The script restarted the container in 3 seconds. I updated the compose file the next morning. Before the Container Doctor, this would've been a 2-hour outage overnight.</p>
<h3 id="heading-incident-2-connection-pool-exhausted-week-2">Incident 2: Connection Pool Exhausted (Week 2)</h3>
<pre><code class="language-plaintext">ERROR: database connection pool exhausted
ERROR: cannot create new pool entry
ERROR: QueuePool limit of 5 overflow 0 reached
</code></pre>
<p>Claude caught that my pool size was too small for the number of workers:</p>
<pre><code class="language-json">{
    "root_cause": "SQLAlchemy connection pool (size=5) can't keep up with 8 concurrent Gunicorn workers. Each worker holds a connection during request processing.",
    "severity": "high",
    "suggested_fix": "Set POOL_SIZE=20 and add POOL_TIMEOUT=30. Long-term: add PgBouncer as a connection pooler.",
    "auto_restart_safe": true,
    "config_suggestions": ["POOL_SIZE=20", "POOL_TIMEOUT=30", "POOL_RECYCLE=3600"],
    "likely_recurring": true,
    "estimated_impact": "New API requests will hang for 30s then timeout. Existing requests may complete but slowly."
}
</code></pre>
<h3 id="heading-incident-3-transient-timeout-week-2">Incident 3: Transient Timeout (Week 2)</h3>
<pre><code class="language-plaintext">WARN: timeout connecting to upstream service
WARN: retrying request (attempt 2/3)
INFO: request succeeded on retry
</code></pre>
<p>Claude correctly identified this as a non-issue:</p>
<pre><code class="language-json">{
    "root_cause": "Transient network timeout during a DNS resolution hiccup. Retries succeeded.",
    "severity": "low",
    "suggested_fix": "No action needed. This is expected during brief network blips. Only investigate if frequency increases.",
    "auto_restart_safe": false,
    "config_suggestions": [],
    "likely_recurring": false,
    "estimated_impact": "Minimal. Individual requests delayed by ~2s but all completed."
}
</code></pre>
<p>No restart. No alert (I filter low-severity from Slack pings). This is the right call: restarting on every transient timeout causes more downtime than it prevents.</p>
<h3 id="heading-incident-4-disk-full-week-3">Incident 4: Disk Full (Week 3)</h3>
<pre><code class="language-plaintext">ERROR: could not write to temporary file: No space left on device
FATAL: data directory has no space
</code></pre>
<pre><code class="language-json">{
    "root_cause": "Postgres data volume is full. WAL files and temporary sort files consumed all available space.",
    "severity": "high",
    "suggested_fix": "1. Clean WAL files: SELECT pg_switch_wal(). 2. Increase volume size. 3. Add log rotation. 4. Set max_wal_size=1GB.",
    "auto_restart_safe": false,
    "config_suggestions": ["max_wal_size=1GB", "log_rotation_age=1d"],
    "likely_recurring": true,
    "estimated_impact": "Database is read-only. All writes fail. API returns 500 on any mutation."
}
</code></pre>
<p>Notice Claude said <code>auto_restart_safe: false</code> here. Restarting Postgres when the disk is full can corrupt data. The script didn't touch it. It just sent me a detailed Slack alert at 4 AM. I cleaned up the WAL files the next morning. Good call by Claude.</p>
<h2 id="heading-cost-breakdown-what-this-actually-costs">Cost Breakdown – What This Actually Costs</h2>
<p>After 3 weeks of running this on 5 containers:</p>
<ul>
<li><p><strong>Claude API</strong>: ~$3.80/month (with rate limiting and deduplication)</p>
</li>
<li><p><strong>Linode compute</strong>: $0 extra (the Container Doctor uses about 50MB RAM)</p>
</li>
<li><p><strong>Slack</strong>: Free tier</p>
</li>
<li><p><strong>My time saved</strong>: ~2-3 hours/month of 3 AM debugging</p>
</li>
</ul>
<p>Without rate limiting, my first week cost $8 in API calls. The deduplication + rate limiter brought that down dramatically. Most of my containers run fine. The script only calls Claude when something actually breaks.</p>
<p>If you're monitoring more containers or have noisier logs, expect higher costs. The <code>MAX_DIAGNOSES_PER_HOUR</code> setting is your budget knob.</p>
<h2 id="heading-security-considerations">Security Considerations</h2>
<p>Let's talk about the elephant in the room: the Docker socket.</p>
<p>Mounting <code>/var/run/docker.sock</code> gives the Container Doctor <strong>root-equivalent access</strong> to your Docker daemon. It can start, stop, and remove any container. It can pull images. It can exec into running containers. If someone compromises the Container Doctor, they own your entire Docker host.</p>
<p>Here's how I mitigate this:</p>
<ol>
<li><p><strong>Network isolation</strong>: The Container Doctor's health endpoint is only exposed on localhost. In production, put it behind a reverse proxy with auth.</p>
</li>
<li><p><strong>Read-mostly access</strong>: The script only <em>reads</em> logs and <em>restarts</em> containers. It never execs into containers, pulls images, or modifies volumes.</p>
</li>
<li><p><strong>No external inputs</strong>: The script doesn't accept commands from Slack or any external source. It's outbound-only (logs out, alerts out).</p>
</li>
<li><p><strong>API key rotation</strong>: I rotate the Anthropic API key monthly. If the container is compromised, the key has limited blast radius.</p>
</li>
</ol>
<p>For a more secure setup, consider Docker's <code>--read-only</code> flag on the socket mount and a tool like <a href="https://github.com/Tecnativa/docker-socket-proxy">docker-socket-proxy</a> to restrict which API calls the Container Doctor can make.</p>
<h2 id="heading-what-id-do-differently">What I'd Do Differently</h2>
<p>After 3 weeks in production, here's my honest retrospective:</p>
<p><strong>I'd use structured logging from day one.</strong> My regex-based error detection catches too many false positives. A JSON log format with severity levels would make detection way more accurate.</p>
<p><strong>I'd add per-container policies.</strong> Right now, every container gets the same treatment. But you probably want different rules for a database vs a web server. Never auto-restart a database. Always auto-restart a stateless web server.</p>
<p><strong>I'd build a simple web UI.</strong> The <code>/history</code> endpoint returns JSON, but a small React dashboard showing a timeline of incidents, fix success rates, and cost tracking would be much more useful.</p>
<p><strong>I'd try local models first.</strong> For simple errors (OOM, connection refused), a small local model running on Ollama could handle the diagnosis without any API cost. Reserve Claude for the weird, complex stack traces where you actually need strong reasoning.</p>
<p><strong>I'd add a "learning mode."</strong> Run the Container Doctor in observe-only mode for a week. Let it diagnose everything but fix nothing. Review the diagnoses manually. Once you trust its judgment, flip on auto-fix. This builds confidence before you give it restart power.</p>
<h2 id="heading-whats-next">What's Next?</h2>
<p>If you found this useful, I write about Docker, AI tools, and developer workflows every week. I'm Balajee Asish – Docker Captain, freeCodeCamp contributor, and currently building my way through the AI tools space one project at a time.</p>
<p>Got questions or built something similar? Drop a comment below or find me on <a href="https://github.com/balajee-asish">GitHub</a> and <a href="https://linkedin.com/in/balajee-asish">LinkedIn</a>.</p>
<p>Happy building.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Ship a Production-Ready RAG App with FAISS (Guardrails, Evals, and Fallbacks) ]]>
                </title>
                <description>
                    <![CDATA[ Most LLM applications look great in a high-fidelity demo. Then they hit the hands of real users and start failing in very predictable yet damaging ways. They answer questions they should not, they bre ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-rag-app-faiss-fastapi/</link>
                <guid isPermaLink="false">69b841572ad6ae5184d54317</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FastAPI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ vector database ]]>
                    </category>
                
                    <category>
                        <![CDATA[ faiss ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chidozie Managwu ]]>
                </dc:creator>
                <pubDate>Mon, 16 Mar 2026 17:43:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f9da3ad9-e285-4ce1-acb7-ad119579971c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most LLM applications look great in a high-fidelity demo. Then they hit the hands of real users and start failing in very predictable yet damaging ways.</p>
<p>They answer questions they should not, they break when document retrieval is weak, they time out due to network latency, and nobody can tell exactly what happened because there are no logs and no tests.</p>
<p>In this tutorial, you’ll build a beginner-friendly Retrieval Augmented Generation (RAG) application designed to survive production realities. This isn’t just a script that calls an API. It’s a system featuring a FastAPI backend, a persisted FAISS vector store, and essential safety guardrails (including a retrieval gate and fallbacks).</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ol>
<li><p><a href="#heading-why-rag-alone-does-not-equal-productionready">Why RAG Alone Does Not Equal Production-Ready</a></p>
</li>
<li><p><a href="#heading-the-architecture-you-are-building">The Architecture You Are Building</a></p>
</li>
<li><p><a href="#heading-project-setup-and-structure">Project Setup and Structure</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-rag-layer-with-faiss">How to Build the RAG Layer with FAISS</a></p>
</li>
<li><p><a href="#heading-how-to-add-the-llm-call-with-structured-output">How to Add the LLM Call with Structured Output</a></p>
</li>
<li><p><a href="#heading-how-to-add-guardrails-retrieval-gate-and-fallbacks">How to Add Guardrails: Retrieval Gate and Fallbacks</a></p>
</li>
<li><p><a href="#heading-fast-api-app-creating-the-answer-endpoint">FastAPI App: Creating the /answer Endpoint</a></p>
</li>
<li><p><a href="#heading-how-to-add-beginnerfriendly-evals">How to Add Beginner-Friendly Evals</a></p>
</li>
<li><p><a href="#heading-what-to-improve-next-realistic-upgrades">What to Improve Next: Realistic Upgrades</a></p>
</li>
</ol>
<h2 id="heading-why-rag-alone-does-not-equal-production-ready">Why RAG Alone Does Not Equal Production-Ready</h2>
<p>Retrieval Augmented Generation (RAG) is often hailed as the hallucination killer. By grounding the model in retrieved text, we provide it with the facts it needs to be accurate. But simply connecting a vector database to an LLM isn’t enough for a production environment.</p>
<p>Production issues usually arise from the silent failures in the system surrounding the model:</p>
<ul>
<li><p><strong>Weak retrieval:</strong> If the app retrieves irrelevant chunks of text, the model tries to bridge the gap by inventing an answer anyway. Without a designated “I do not know” path, the model is essentially forced to hallucinate.</p>
</li>
<li><p><strong>Lack of visibility:</strong> Without structured outputs and basic logging, you can’t tell if bad retrieval, a confusing prompt, or a model update caused a wrong answer.</p>
</li>
<li><p><strong>Fragility:</strong> A simple API timeout or malformed provider response becomes a user-facing outage if you don’t implement fallbacks.</p>
</li>
<li><p><strong>No regression testing:</strong> In traditional software, we have unit tests. In AI, we need evals. Without them, a small tweak to your prompt might fix one issue but break ten others without you realising it.</p>
</li>
</ul>
<p>We’ll solve each of these issues systematically in this guide.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This tutorial is beginner-friendly, but it assumes you have a few basics in place so you can focus on building a robust RAG system instead of getting stuck on setup issues.</p>
<h3 id="heading-knowledge">Knowledge</h3>
<p>You should be comfortable with:</p>
<ul>
<li><p><strong>Python fundamentals</strong> (functions, modules, virtual environments)</p>
</li>
<li><p><strong>Basic HTTP + JSON</strong> (requests, response payloads)</p>
</li>
<li><p><strong>APIs with FastAPI</strong> (what an endpoint is and how to run a server)</p>
</li>
<li><p><strong>High-level LLM concepts</strong> (prompting, temperature, structured outputs)</p>
</li>
</ul>
<h3 id="heading-tools-accounts">Tools + Accounts</h3>
<p>You’ll need:</p>
<ul>
<li><p><strong>Python 3.10+</strong></p>
</li>
<li><p>A working <strong>OpenAI-compatible API key</strong> (OpenAI or any provider that supports the same request/response shape)</p>
</li>
<li><p>A local environment where you can run a FastAPI app (Mac/Linux/Windows)</p>
</li>
</ul>
<h3 id="heading-what-this-tutorial-covers-and-what-it-doesnt">What This Tutorial Covers (and What It Doesn’t)</h3>
<p>We’ll build a production-minded baseline:</p>
<ul>
<li><p>A <strong>FAISS-backed retriever</strong> with a persisted index + metadata</p>
</li>
<li><p>A <strong>retrieval gate</strong> to prevent “forced hallucination”</p>
</li>
<li><p><strong>Structured JSON outputs</strong> so your backend is stable</p>
</li>
<li><p><strong>Fallback behavior</strong> for timeouts and provider errors</p>
</li>
<li><p>A small <strong>eval harness</strong> to prevent regressions</p>
</li>
</ul>
<p>We won’t implement advanced upgrades such as rerankers, semantic chunking, auth, background jobs beyond a roadmap at the end.</p>
<h2 id="heading-the-architecture-you-are-building">The Architecture You Are Building</h2>
<p>The flow of our application follows a disciplined path so every answer is grounded in evidence:</p>
<ol>
<li><p><strong>User query:</strong> The user submits a question via a FastAPI endpoint.</p>
</li>
<li><p><strong>Retrieval:</strong> The system embeds the question and retrieves the top-k most similar document chunks.</p>
</li>
<li><p><strong>The retrieval gate:</strong> We evaluate the similarity score. If the context is not relevant enough, we stop immediately and refuse the query.</p>
</li>
<li><p><strong>Augmentation and generation:</strong> If the gate passes, we send a context-augmented prompt to the LLM.</p>
</li>
<li><p><strong>Structured response:</strong> The model returns a JSON object containing the answer, sources used, and a confidence level.</p>
</li>
</ol>
<h2 id="heading-project-setup-and-structure">Project Setup and Structure</h2>
<p>To keep things organized and maintainable, we’ll use a modular structure. This allows you to swap out your LLM provider or your vector database without rewriting your entire core application.</p>
<h3 id="heading-project-structure">Project Structure</h3>
<pre><code class="language-python">.
├── app.py              # FastAPI entry point and API logic
├── rag.py              # FAISS index, persistence, and document retrieval
├── llm.py              # LLM API interface and JSON parsing
├── prompts.py          # Centralized prompt templates
├── data/               # Source .txt documents
├── index/              # Persisted FAISS index and metadata
└── evals/              # Evaluation dataset and runner script
    ├── eval_set.json
    └── run_evals.py
</code></pre>
<h3 id="heading-install-dependencies">Install Dependencies</h3>
<p>First, create a virtual environment to isolate your project:</p>
<pre><code class="language-python">python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate
pip install fastapi uvicorn faiss-cpu numpy pydantic requests python-dotenv
</code></pre>
<h3 id="heading-configure-the-environment">Configure the Environment</h3>
<p>Create a <code>.env</code> file in the root directory. We are targeting OpenAI-compatible providers:</p>
<pre><code class="language-python">OPENAI_API_KEY=your_actual_api_key_here
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
</code></pre>
<p>Important note on compatibility: The code below assumes an OpenAI-style API. If you use a provider that is not compatible, you must change the URL, headers (for example <code>X-API-Key</code>), and the way you extract embeddings and final message content in <code>embed_texts()</code> and <code>call_llm()</code>.</p>
<h2 id="heading-how-to-build-the-rag-layer-with-faiss">How to Build the RAG Layer with FAISS</h2>
<p>In <code>rag.py</code>, we handle the “Retriever” part of RAG. This involves turning raw text into mathematical vectors that the computer can compare.</p>
<h3 id="heading-what-is-faiss-and-what-does-it-do">What is FAISS (and What Does It Do)?</h3>
<p><strong>FAISS</strong> (Facebook AI Similarity Search) is a fast library for vector similarity search. In a RAG system, each chunk of text becomes an embedding vector (a list of floats). FAISS stores those vectors in an index so you can quickly ask:</p>
<blockquote>
<p>“Given this question embedding, which document chunks are closest to it?”</p>
</blockquote>
<p>In this tutorial, we use <code>IndexFlatIP</code> inner product and normalise vectors with <code>faiss.normalize_L2(...)</code>. With normalised vectors, the inner product behaves like <strong>cosine similarity</strong>, giving us a stable score we can use for a retrieval gate.</p>
<h3 id="heading-chunking-strategy-with-overlap">Chunking Strategy With Overlap</h3>
<p>We’ll use chunking with overlap. If we split a document at exactly 1,000 characters, we might cut a sentence in half, losing its meaning. By using an overlap, for example, 200 characters, we ensure that the end of one chunk and the beginning of the next share context.</p>
<h3 id="heading-implementation-of-ragpy">Implementation of <code>rag.py</code></h3>
<pre><code class="language-python">import os
import faiss
import numpy as np
import requests
import json
from typing import List, Dict
from dotenv import load_dotenv

load_dotenv()

INDEX_PATH = "index/faiss.index"
META_PATH = "index/meta.json"

def chunk_text(text: str, size: int = 1000, overlap: int = 200) -&gt; List[str]:
    chunks = []
    step = max(1, size - overlap)
    for i in range(0, len(text), step):
        chunk = text[i : i + size].strip()
        if chunk:
            chunks.append(chunk)
    return chunks

def embed_texts(texts: List[str]) -&gt; np.ndarray:
    # Note: If your provider is not OpenAI-compatible, change this URL and headers
    url = f"{os.getenv('OPENAI_BASE_URL')}/embeddings"
    headers = {"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}"}
    payload = {"input": texts, "model": "text-embedding-3-small"}

    resp = requests.post(url, headers=headers, json=payload, timeout=30)
    resp.raise_for_status()
    # If your provider uses a different response format, change the line below
    vectors = np.array([item["embedding"] for item in resp.json()["data"]], dtype="float32")
    return vectors

def build_index() -&gt; None:
    all_chunks: List[str] = []
    metadata: List[Dict] = []

    if not os.path.exists("data"):
        os.makedirs("data")
        return

    for file in os.listdir("data"):
        if not file.endswith(".txt"):
            continue

        with open(f"data/{file}", "r", encoding="utf-8") as f:
            text = f.read()

        chunks = chunk_text(text)
        all_chunks.extend(chunks)
        for c in chunks:
            metadata.append({"source": file, "text": c})

    if not all_chunks:
        return

    embeddings = embed_texts(all_chunks)
    faiss.normalize_L2(embeddings)

    dim = embeddings.shape[1]
    index = faiss.IndexFlatIP(dim)
    index.add(embeddings)

    os.makedirs("index", exist_ok=True)
    faiss.write_index(index, INDEX_PATH)

    with open(META_PATH, "w", encoding="utf-8") as f:
        json.dump(metadata, f, ensure_ascii=False)

def load_index():
    if not (os.path.exists(INDEX_PATH) and os.path.exists(META_PATH)):
        raise FileNotFoundError(
            "FAISS index not found. Add .txt files to data/ and run build_index()."
        )

    index = faiss.read_index(INDEX_PATH)
    with open(META_PATH, "r", encoding="utf-8") as f:
        metadata = json.load(f)
    return index, metadata

def retrieve(query: str, k: int = 5) -&gt; List[Dict]:
    index, metadata = load_index()

    q_emb = embed_texts([query])
    faiss.normalize_L2(q_emb)

    scores, ids = index.search(q_emb, k)
    results = []
    for score, idx in zip(scores[0], ids[0]):
        if idx == -1:
            continue
        m = metadata[idx]
        results.append(
            {"score": float(score), "source": m["source"], "text": m["text"], "id": int(idx)}
        )
    return results
</code></pre>
<h2 id="heading-how-to-add-the-llm-call-with-structured-output">How to Add the LLM Call with Structured Output</h2>
<p>A major failure point in AI apps is the “chatty” nature of LLMs. If your backend expects a list of sources but the LLM returns conversational filler, your code will crash.</p>
<p>We solve this with <strong>structured output</strong>: instruct the model to return a strict JSON object, then parse it safely.</p>
<h3 id="heading-implementation-of-llmpy">Implementation of <code>llm.py</code></h3>
<pre><code class="language-python">import json
import requests
import os
from typing import Dict, Any

def call_llm(system_prompt: str, user_prompt: str) -&gt; Dict[str, Any]:
    # Note: Change URL/Headers if using a non-OpenAI compatible provider
    url = f"{os.getenv('OPENAI_BASE_URL')}/chat/completions"
    headers = {
        "Authorization": f"Bearer {os.getenv('OPENAI_API_KEY')}",
        "Content-Type": "application/json",
    }

    payload = {
        "model": os.getenv("OPENAI_MODEL"),
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        "response_format": {"type": "json_object"},
        "temperature": 0,
    }

    try:
        resp = requests.post(url, headers=headers, json=payload, timeout=30)
        resp.raise_for_status()
        content = resp.json()["choices"][0]["message"]["content"]

        parsed = json.loads(content)
        parsed.setdefault("answer", "")
        parsed.setdefault("refusal", False)
        parsed.setdefault("confidence", "medium")
        parsed.setdefault("sources", [])
        return parsed

    except (requests.Timeout, requests.ConnectionError):
        return {
            "answer": "The system is temporarily unavailable (network issue). Please try again.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "error_type": "network_error",
        }
    except Exception:
        return {
            "answer": "A system error occurred while generating the answer.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "error_type": "unknown_error",
        }
</code></pre>
<h2 id="heading-how-to-add-guardrails-retrieval-gate-and-fallbacks">How to Add Guardrails: Retrieval Gate and Fallbacks</h2>
<p>Guardrails are interceptors. They sit between the user and the model to prevent predictable failures.</p>
<h3 id="heading-the-retrieval-gate-how-it-works-and-how-to-add-it">The Retrieval Gate: How It Works and How to Add It</h3>
<p>In a standard RAG pipeline, the system always calls the LLM. If the user asks an irrelevant question, the retriever will still return the “closest” (but wrong) chunks.</p>
<p>The solution is the retrieval gate:</p>
<ol>
<li><p>Retrieve top-k chunks and get the <strong>top similarity score</strong></p>
</li>
<li><p>If the score is below a threshold (for example <code>0.30</code>), refuse immediately</p>
</li>
<li><p>Only call the LLM when retrieval is strong enough to ground the answer</p>
</li>
</ol>
<p>A threshold of <code>0.30</code> is a reasonable starting point when using normalised cosine similarity, but you should tune it using evals (next section).</p>
<h3 id="heading-fallbacks-and-why-they-matter">Fallbacks and Why They Matter</h3>
<p>Fallbacks ensure that if an API fails or times out, the user gets a helpful message instead of a crash. They also keep your API response shape consistent, which prevents frontend errors and makes logging meaningful.</p>
<p>In this tutorial, fallbacks are implemented inside <code>call_llm()</code> so your FastAPI layer stays simple.</p>
<h2 id="heading-fastapi-app-creating-the-answer-endpoint">FastAPI App: Creating the /answer Endpoint</h2>
<p>The <code>app.py</code> file is the conductor. It ties retrieval, guardrails, prompting, and generation together.</p>
<h3 id="heading-implementation-of-apppy">Implementation of <code>app.py</code></h3>
<pre><code class="language-python">from fastapi import FastAPI
from pydantic import BaseModel
from rag import retrieve
from llm import call_llm
import prompts
import time
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rag_app")

app = FastAPI(title="Production-Ready RAG")

class QueryRequest(BaseModel):
    question: str

@app.post("/answer")
async def get_answer(req: QueryRequest):
    start_time = time.time()
    question = (req.question or "").strip()

    if not question:
        return {
            "answer": "Please provide a non-empty question.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "latency_sec": round(time.time() - start_time, 2),
        }

    # 1) Retrieval
    results = retrieve(question, k=5)
    top_score = results[0]["score"] if results else 0.0

    logger.info("query=%r top_score=%.3f num_results=%d", question, top_score, len(results))

    # 2) Retrieval Gate (Guardrail)
    if top_score &lt; 0.30:
        return {
            "answer": "I do not have documents to answer that question.",
            "refusal": True,
            "confidence": "low",
            "sources": [],
            "latency_sec": round(time.time() - start_time, 2),
            "retrieval": {"top_score": top_score, "k": 5},
        }

    # 3) Augment
    context_text = "\n\n".join([f"Source {r['source']}: {r['text']}" for r in results])
    user_prompt = f"Context:\n{context_text}\n\nQuestion: {question}"

    # 4) Generation with Fallback
    response = call_llm(prompts.SYSTEM_PROMPT, user_prompt)

    # 5) Attach debug metadata
    response["latency_sec"] = round(time.time() - start_time, 2)
    response["retrieval"] = {"top_score": top_score, "k": 5}
    return response
</code></pre>
<h2 id="heading-centralized-prompt-template-promptspy">Centralized Prompt – Template: prompts.py</h2>
<p>A small but important habit: keep prompts centralised so they’re versionable and easy to evaluate.</p>
<h3 id="heading-example-promptspy">Example <code>prompts.py</code></h3>
<pre><code class="language-python">SYSTEM_PROMPT = """You are a RAG assistant. Use ONLY the provided Context to answer.
If the context does not contain the answer, respond with refusal=true.

Return a valid JSON object with exactly these keys:
- answer: string
- refusal: boolean
- confidence: "low" | "medium" | "high"
- sources: array of strings (source filenames you used)

Do not include any extra keys. Do not include markdown. Do not include commentary."""
</code></pre>
<h2 id="heading-how-to-add-beginner-friendly-evals">How to Add Beginner-Friendly Evals</h2>
<p>In AI systems, outputs are probabilistic. This makes testing harder than traditional software. Evals (evaluations) are a set of “golden questions” and “expected behaviours” you run repeatedly to detect regressions.</p>
<p>Instead of “does it output exactly this string,” you test:</p>
<ul>
<li><p>Should the app <strong>refuse</strong> when the retrieval is weak?</p>
</li>
<li><p>When it answers, does it include <strong>sources</strong>?</p>
</li>
<li><p>Is the behaviour stable across prompt tweaks and model changes?</p>
</li>
</ul>
<h3 id="heading-step-1-create-evalsevalsetjson">Step 1: Create <code>evals/eval_set.json</code></h3>
<p>This should contain both positive and negative cases.</p>
<pre><code class="language-json">[
  {
    "id": "in_scope_01",
    "question": "What is a retrieval gate and why is it important?",
    "expect_refusal": false,
    "notes": "Should explain gating and relate it to hallucination prevention."
  },
  {
    "id": "out_of_scope_01",
    "question": "What is the capital of France?",
    "expect_refusal": true,
    "notes": "If the knowledge base only includes our docs, the app should refuse."
  },
  {
    "id": "edge_01",
    "question": "",
    "expect_refusal": true,
    "notes": "Empty input should not call the LLM."
  }
]
</code></pre>
<h3 id="heading-step-2-create-evalsrunevalspy">Step 2: Create <code>evals/run_evals.py</code></h3>
<p>This runner calls your API endpoint (end-to-end) and checks expected behaviours.</p>
<pre><code class="language-python">import json
import requests

API_URL = "http://127.0.0.1:8000/answer"

def run():
    with open("evals/eval_set.json", "r", encoding="utf-8") as f:
        cases = json.load(f)

    passed = 0
    failed = 0

    for case in cases:
        resp = requests.post(API_URL, json={"question": case["question"]}, timeout=60)
        resp.raise_for_status()
        out = resp.json()

        got_refusal = bool(out.get("refusal", False))
        expect_refusal = bool(case["expect_refusal"])

        ok = (got_refusal == expect_refusal)

        # Beginner-friendly: if it answers, sources should exist and be a list
        if not got_refusal:
            ok = ok and isinstance(out.get("sources"), list)

        if ok:
            passed += 1
            print(f"PASS {case['id']}")
        else:
            failed += 1
            print(f"FAIL {case['id']} expected_refusal={expect_refusal} got_refusal={got_refusal}")
            print("Output:", json.dumps(out, indent=2))

    print(f"\nDone. Passed={passed} Failed={failed}")
    if failed:
        raise SystemExit(1)

if __name__ == "__main__":
    run()
</code></pre>
<h3 id="heading-how-to-use-evals-in-practice">How to Use Evals in Practice</h3>
<p>Run your server:</p>
<pre><code class="language-python">uvicorn app:app --reload
</code></pre>
<p>In another terminal, run evals:</p>
<pre><code class="language-python">python evals/run_evals.py
</code></pre>
<p>If an eval fails, you have a concrete signal that something changed in retrieval, gating, prompting, or provider behaviour.</p>
<h2 id="heading-what-to-improve-next-realistic-upgrades">What to Improve Next: Realistic Upgrades</h2>
<p>Building a reliable RAG app is iterative. Here are realistic next steps:</p>
<ul>
<li><p><strong>Semantic chunking:</strong> Break text based on meaning instead of character count.</p>
</li>
<li><p><strong>Reranking:</strong> Use a cross-encoder reranker to reorder the top-k chunks for higher precision.</p>
</li>
<li><p><strong>Metadata filtering:</strong> Filter results by category, date, or department to reduce false positives.</p>
</li>
<li><p><strong>Better citations:</strong> Store chunk IDs and show exactly which chunk(s) the answer came from.</p>
</li>
<li><p><strong>Observability:</strong> Add request IDs, structured logs, and traces so “what happened?” is answerable.</p>
</li>
<li><p><strong>Async + background indexing:</strong> Move index building to a background job and keep the API responsive.</p>
</li>
</ul>
<h2 id="heading-final-thoughts-production-ready-is-a-set-of-habits">Final Thoughts: Production-Ready Is a Set of Habits</h2>
<p>Building an AI application that survives in the real world is about building a system that is predictable, measurable, and safe.</p>
<ul>
<li><p><strong>Retrieval quality is measurable:</strong> Use similarity scores to gate your LLM.</p>
</li>
<li><p><strong>Refusal is a feature:</strong> It is better to say “I do not know” than to lie.</p>
</li>
<li><p><strong>Fallbacks are mandatory:</strong> Design for the moment the API goes down.</p>
</li>
<li><p><strong>Evals prevent regressions:</strong> Never deploy a change without running your tests.</p>
</li>
</ul>
<h2 id="heading-about-me">About Me</h2>
<p>I am Chidozie Managwu, an award-winning AI Product Architect and founder focused on helping global tech talent build real, production-ready skills. I contribute to global AI initiatives as a GAFAI Delegate and lead AI Titans Network, a community for developers learning how to ship AI products.</p>
<p>My work has been recognized with the Global Tech Hero award and featured on platforms like HackerNoon.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build End-to-End LLM Observability in FastAPI with OpenTelemetry ]]>
                </title>
                <description>
                    <![CDATA[ This article shows how to build end-to-end, code-first LLM observability in a FastAPI application using the OpenTelemetry Python SDK. Instead of relying on vendor-specific agents or opaque SDKs, we wi ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-end-to-end-llm-observability-in-fastapi-with-opentelemetry/</link>
                <guid isPermaLink="false">69b4379c6e27dd07d920f14c</guid>
                
                    <category>
                        <![CDATA[ observability ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OpenTelemetry ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ FastAPI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Jessica Patel ]]>
                </dc:creator>
                <pubDate>Fri, 13 Mar 2026 16:13:16 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c69a589a-2dce-46a1-ac49-a0d0e2c23c6e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This article shows how to build end-to-end, code-first LLM observability in a FastAPI application using the OpenTelemetry Python SDK.</p>
<p>Instead of relying on vendor-specific agents or opaque SDKs, we will manually design traces, spans, and semantic attributes that capture the full lifecycle of an LLM-powered request.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-introduction">Introduction</a></p>
</li>
<li><p><a href="#heading-prerequisites-and-technical-context">Prerequisites and Technical Context</a></p>
</li>
<li><p><a href="#heading-why-llm-observability-is-fundamentally-different">Why LLM Observability Is Fundamentally Different</a></p>
</li>
<li><p><a href="#heading-reference-architecture-a-traceable-rag-request">Reference Architecture: A Traceable RAG Request</a></p>
</li>
<li><p><a href="#heading-reference-architecture-explained">Reference Architecture Explained</a></p>
</li>
<li><p><a href="#heading-why-this-design-is-better-than-simpler-alternatives">Why This Design Is Better Than Simpler Alternatives</a></p>
</li>
<li><p><a href="#heading-llm-models-that-work-best-for-this-architecture">LLM Models That Work Best for This Architecture</a></p>
</li>
<li><p><a href="#heading-opentelemetry-primer-llm-relevant-concepts-only">OpenTelemetry Primer (LLM-Relevant Concepts Only)</a></p>
</li>
<li><p><a href="#heading-designing-llm-aware-spans">Designing LLM-Aware Spans</a></p>
</li>
<li><p><a href="#heading-fastapi-example-end-to-end-llm-spans-complete-and-explained">FastAPI Example: End-to-End LLM Spans (Complete and Explained)</a></p>
</li>
<li><p><a href="#heading-semantic-attributes-best-practices-for-llm-observability">Semantic Attributes: Best Practices for LLM Observability</a></p>
</li>
<li><p><a href="#heading-evaluation-hooks-inside-traces">Evaluation Hooks Inside Traces</a></p>
</li>
<li><p><a href="#heading-exporting-and-visualizing-traces-where-this-fits-with-vendor-tooling">Exporting and Visualizing Traces (Where This Fits with Vendor Tooling)</a></p>
</li>
<li><p><a href="#heading-operational-patterns-and-anti-patterns">Operational Patterns and Anti-Patterns</a></p>
</li>
<li><p><a href="#heading-extending-the-system">Extending the System</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-introduction"><strong>Introduction</strong></h2>
<p>Large Language Models (LLMs) are rapidly becoming a core component of modern software systems. Applications that once relied on deterministic APIs are now incorporating LLM-powered features such as conversational assistants, document summarization, intelligent search, and retrieval-augmented generation (RAG).</p>
<p>While these capabilities unlock new user experiences, they also introduce operational complexity that traditional monitoring approaches were never designed to handle.</p>
<p>Unlike conventional software services, LLM systems are probabilistic by nature. The same request may produce slightly different responses depending on factors such as prompt structure, model configuration, retrieval context, and sampling parameters such as temperature or top-p.</p>
<p>In addition, LLM workloads introduce entirely new operational dimensions such as token consumption, prompt construction latency, inference cost, context window limits, and response quality.</p>
<p>These factors mean that a request can appear technically successful from an infrastructure perspective while still producing an incorrect, hallucinated, or low-quality result.</p>
<p>Traditional observability tools typically focus on infrastructure-level signals such as latency, error rate, and throughput. While these metrics remain important, they are insufficient for understanding how an LLM application behaves in production.</p>
<p>Engineers must also understand what prompt was constructed, which documents were retrieved, how many tokens were consumed, which model configuration was used, and how the final response was evaluated. Without this visibility, debugging LLM behavior becomes extremely difficult and operational costs can quickly spiral out of control.</p>
<p>This is where LLM observability becomes essential. Observability for LLM systems extends beyond infrastructure monitoring. It captures the full lifecycle of an AI-driven request — from user input and context retrieval to prompt construction, model inference, post-processing, and quality evaluation.</p>
<p>When implemented correctly, observability allows teams to answer why the model generated a particular response, which retrieval results influenced the output, how much a request cost in terms of tokens, where latency occurred within the request pipeline, and whether the response passed basic quality or safety checks.</p>
<p>This article demonstrates how to implement end-to-end LLM observability in a FastAPI application using OpenTelemetry. Instead of relying on proprietary monitoring agents or opaque vendor SDKs, we take a code-first approach to instrumentation. By explicitly designing traces, spans, and semantic attributes, we gain precise control over how LLM interactions are observed and analyzed.</p>
<p>Throughout the guide, we will walk through a practical architecture for tracing a retrieval-augmented generation (RAG) workflow, where each stage of the request lifecycle is represented as a trace span. We will explore how to design meaningful span boundaries, capture prompt and model metadata safely, record token usage and cost signals, and attach evaluation results directly to traces.</p>
<p>The article also explains how this instrumentation can be exported to any OpenTelemetry-compatible backend such as Jaeger, Grafana Tempo, or LLM-specific platforms like Phoenix.</p>
<p>By the end of this guide, you will understand how to:</p>
<ul>
<li><p>Structure traces so that each user request maps to a single end-to-end LLM interaction</p>
</li>
<li><p>Design span hierarchies that reflect the logical stages of an LLM pipeline</p>
</li>
<li><p>Capture prompt metadata, model configuration, and token usage safely</p>
</li>
<li><p>Attach evaluation and quality signals to traces for deeper analysis</p>
</li>
<li><p>Export observability data to different backends without changing instrumentation</p>
</li>
</ul>
<p>Most importantly, the goal of this article is not simply to demonstrate how to add telemetry to an application. Instead, it aims to show how to think about observability when building LLM-powered systems.</p>
<p>When LLM operations are treated as first-class components within a distributed system, traces become a powerful tool for debugging, optimization, cost management, and continuous improvement of model behavior.</p>
<h2 id="heading-prerequisites-and-technical-context">Prerequisites and Technical Context</h2>
<p>Before following this guide, you should be familiar with the Python programming language, basic web API concepts, and general microservice architecture. Below are some key tools and concepts used in this article.</p>
<h3 id="heading-fastapi-web-framework">FastAPI (Web Framework)</h3>
<p>FastAPI is used as the primary web framework for the application. It is a modern Python framework designed for building high-performance APIs using standard Python type hints. FastAPI simplifies request validation, serialization, and API documentation while remaining lightweight and fast.</p>
<h3 id="heading-large-language-models-llms">Large Language Models (LLMs)</h3>
<p>Large Language Models (LLMs) are the computational core of the example system. An LLM is a model trained on vast amounts of text data to generate or transform language in ways that resemble human communication. In production environments, LLMs are commonly used for tasks such as conversational interfaces, summarization, and question answering.</p>
<h3 id="heading-observability-concept">Observability (Concept)</h3>
<p>Observability is the overarching concept that connects all the technical pieces in this article. At a high level, observability refers to the ability to understand a system's internal behavior by examining the data it produces during execution. Rather than asking whether a system is simply "up" or "down," observability helps answer deeper questions about why a request behaved a certain way, where latency was introduced, or how different components interacted.</p>
<h3 id="heading-opentelemetry-instrumentation-standard">OpenTelemetry (Instrumentation Standard)</h3>
<p>OpenTelemetry is the mechanism used to implement observability within the application. It is an open, vendor-neutral standard for generating telemetry data such as traces, metrics, and logs. By instrumenting key parts of the LLM workflow, we can observe how requests flow through the system, how long each step takes, and what contextual data influenced the final outcome. OpenTelemetry serves as the foundation for collecting this information in a consistent and portable way, independent of any specific monitoring backend.</p>
<h2 id="heading-why-llm-observability-is-fundamentally-different">Why LLM Observability Is Fundamentally Different</h2>
<p>Traditional observability assumes deterministic behavior: the same input produces the same output. LLM systems violate this assumption. The same request can vary due to prompt template changes, retrieval differences, sampling parameters (temperature, top-p), model version upgrades, and context window truncation.​</p>
<p>As a result, teams need visibility into what the model saw, how it was configured, what it retrieved, how long it took, and how much it cost, all correlated to a single user request. Logs alone are insufficient, and metrics lack dimensionality. Distributed traces are the backbone of LLM observability.</p>
<h2 id="heading-reference-architecture-a-traceable-rag-request">Reference Architecture: A Traceable RAG Request</h2>
<p>A typical FastAPI-based RAG service follows this flow:</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/6979762ba2442d262dacf388/50e7fda4-7407-43d6-8f12-045b8e73c7eb.png" alt="FastAPI Based RAG Service" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Each step is observable, but only if we deliberately instrument it. The goal is one trace per user request, with child spans representing each logical LLM step.</p>
<h2 id="heading-reference-architecture-explained">Reference Architecture Explained</h2>
<h3 id="heading-client-sends-a-request-to-chat">Client Sends a Request to /chat</h3>
<p>The architecture begins when a client sends a request to the <code>/chat</code> endpoint. This request typically contains the user's query along with any session or conversation context required by the application.</p>
<p>Keeping the client interface minimal and well-defined is intentional: it ensures the backend receives a predictable input shape and prevents application-specific logic from leaking into downstream LLM processing.</p>
<p>From an observability perspective, this request marks the start of a single end-to-end trace, allowing every subsequent operation to be correlated back to the original user action.</p>
<h3 id="heading-fastapi-validates-input-and-authenticates-the-user">FastAPI Validates Input and Authenticates the User</h3>
<p>Once the request reaches the service, FastAPI performs schema validation and authentication. Validation guarantees that only well-formed inputs proceed through the pipeline, while authentication ensures that expensive LLM operations are only executed for authorized users.</p>
<p>Placing this step early reduces unnecessary computation and protects the system from abuse. It also improves trace quality by ensuring that all observed requests represent legitimate execution paths rather than malformed or rejected traffic.</p>
<h3 id="heading-retriever-queries-the-vector-database">Retriever Queries the Vector Database​</h3>
<p>After validation, the system queries a vector database to retrieve documents relevant to the user's request. This retrieval step is the foundation of retrieval-augmented generation (RAG). By grounding the LLM in external knowledge, the system improves factual accuracy and reduces hallucinations.</p>
<p>Separating retrieval from generation allows teams to tune similarity thresholds, embedding models, and top-k values independently, and it makes it easier to diagnose whether poor responses are caused by bad retrieval or model behavior.</p>
<h3 id="heading-prompt-is-assembled-using-retrieved-documents">Prompt Is Assembled Using Retrieved Documents</h3>
<p>With relevant documents in hand, the system constructs the final prompt that will be sent to the LLM. This step combines the user query, retrieved context, system instructions, and formatting rules into a single structured prompt.</p>
<p>Making prompt assembly an explicit stage enables prompt versioning, experimentation, and observability. It also provides a natural place to detect issues such as context window overflows or excessive prompt size before invoking the model.</p>
<h3 id="heading-llm-api-is-invoked">LLM API Is Invoked</h3>
<p>The LLM API call is the most expensive and non-deterministic operation in the pipeline, which is why it occurs only after all preparatory work is complete. At this stage, the model receives a fully constructed prompt and produces a response based on its configuration parameters.</p>
<p>This step is the primary focus of latency, cost, and reliability controls such as retries, timeouts, and circuit breakers. From an observability standpoint, this span becomes the anchor for token usage, cost attribution, and prompt-level debugging.</p>
<h3 id="heading-response-is-post-processed-and-returned">Response Is Post-Processed and Returned</h3>
<p>After the LLM returns a response, the system performs post-processing before sending the result back to the client. This may include formatting, filtering, validation, or enrichment of the output. Post-processing acts as a final safeguard against malformed or low-quality responses and ensures consistency with application requirements. It also provides a clean boundary for attaching evaluation signals, such as response length, relevance scores, or truncation indicators, before the request completes.</p>
<h2 id="heading-why-this-design-is-better-than-simpler-alternatives">Why This Design Is Better Than Simpler Alternatives</h2>
<p>This architecture intentionally avoids coupling responsibilities together. Validation, retrieval, prompt construction, model execution, and response handling are all distinct steps. This separation makes the system easier to test, easier to observe, and easier to evolve. When something fails, engineers can identify <em>where</em> and <em>why</em> rather than treating the LLM as a black box.​</p>
<p>Compared to a monolithic "send user input directly to the LLM" approach, this design offers better correctness, lower cost, and higher resilience. It also aligns naturally with distributed tracing, since each block maps cleanly to a trace span with a clear semantic purpose. As the system grows, additional features such as caching, fallback models, or policy enforcement can be added without destabilizing the entire flow.​</p>
<p>Most importantly, this architecture treats the LLM as one component in a larger system, not the system itself. That mindset is essential for building reliable production applications.</p>
<h2 id="heading-llm-models-that-work-best-for-this-architecture">LLM Models That Work Best for This Architecture</h2>
<p>This architecture is model-agnostic, but certain model characteristics work particularly well with retrieval-augmented workflows.</p>
<p>Models with strong instruction-following and reasoning capabilities tend to perform best, especially when prompts include structured context from retrieved documents. General-purpose models such as GPT-4-class systems perform well when accuracy and reasoning depth are critical.</p>
<p>For lower-latency or cost-sensitive use cases, smaller instruction-tuned models can be effective when paired with high-quality retrieval. Open-source models such as LLaMA-derived or Mistral-based systems also fit well into this architecture, particularly when deployed behind a private inference endpoint.​</p>
<p>The key requirement is not the model itself, but how it is used. Models that can reliably ground their responses in provided context, respect system instructions, and produce stable outputs under varying prompts integrate most cleanly into this design. Because retrieval and prompt construction are explicit stages, models can be swapped or compared without changing the overall system structure.</p>
<h2 id="heading-opentelemetry-primer-llm-relevant-concepts-only">OpenTelemetry Primer (LLM-Relevant Concepts Only)</h2>
<p>OpenTelemetry defines three core types of telemetry data: traces, metrics, and logs. For LLM systems, traces are the most important. To make them useful, you need to understand a few building blocks:</p>
<ul>
<li><p>a <strong>trace</strong> represents a single end-to-end request</p>
</li>
<li><p>a <strong>span</strong> is a timed operation within that trace</p>
</li>
<li><p><strong>attributes</strong> are key–value metadata attached to spans</p>
</li>
<li><p><strong>events</strong> are time-stamped annotations</p>
</li>
<li><p><strong>context propagation</strong> ensures child spans attach to the correct parent.</p>
</li>
</ul>
<p>FastAPI’s async nature makes correct context propagation essential, but OpenTelemetry’s Python SDK handles this as long as spans are created correctly.</p>
<p>With those concepts in place, the next step is to wire OpenTelemetry into the app. Start by configuring the OpenTelemetry SDK in FastAPI: define a <code>TracerProvider</code>, attach a <code>Resource</code> (service name and environment), configure an exporter (Jaeger, Tempo, Phoenix, and so on), and enable FastAPI auto-instrumentation.</p>
<h2 id="heading-designing-llm-aware-spans">Designing LLM-Aware Spans</h2>
<h3 id="heading-span-taxonomy">Span Taxonomy</h3>
<p>A clean span hierarchy is critical. In this guide, a single <code>http.request</code> span (usually auto-generated) acts as the root, and it contains child spans such as <code>rag.retrieval</code>, <code>rag.prompt.build</code>, <code>llm.call</code>, <code>llm.postprocess</code>, and, optionally, <code>llm.eval</code>. Each of these spans represents a logical unit of work rather than an implementation detail.</p>
<h3 id="heading-span-boundaries">Span Boundaries</h3>
<p>Getting span boundaries right is just as important as picking the right span names. Avoid extremes like wrapping the entire LLM workflow in one giant span, creating a separate span for every token, or dumping all data into logs.</p>
<p>Instead, aim for a few coarse-grained spans that each represent a meaningful step in the request, enrich them with well-chosen attributes, and use events to mark important milestones within a span rather than splitting everything into smaller spans.</p>
<h3 id="heading-instrumenting-the-llm-call">Instrumenting the LLM Call</h3>
<p>When instrumenting the LLM call, treat it as the most critical span in the trace. Whether you are calling OpenAI, Anthropic, or another provider, start the span immediately before the API request and end it only after the full response (or stream) is complete.</p>
<p>Within that span, capture retries, timeouts, and errors so it becomes the central place for latency analysis, cost attribution, and prompt debugging.</p>
<p>For streaming responses, you can emit events for each chunk to track progress, but avoid creating separate child spans unless you truly need fine-grained timing.</p>
<h2 id="heading-fastapi-example-end-to-end-llm-spans-complete-and-explained">FastAPI Example: End-to-End LLM Spans (Complete and Explained)</h2>
<pre><code class="language-python">from fastapi import FastAPI, Request
from opentelemetry import trace
from opentelemetry.trace import Tracer
from typing import List
import asyncio
import hashlib

# Obtain a tracer instance from OpenTelemetry.
# All spans created with this tracer will be part of the same distributed
# tracing system and exported to the configured backend.
tracer: Tracer = trace.get_tracer(__name__)

# Initialize the FastAPI application.
app = FastAPI()

# Helper functions used by the observable endpoint
async def retrieve_documents(query: str) -&gt; List[str]:
    """
    Simulate document retrieval (e.g., vector search or knowledge base lookup).
    This function represents the retrieval stage in a RAG pipeline.
    In a real system, this might query a vector database or search index.
    """
    await asyncio.sleep(0.05)  # Simulate I/O latency
    return [
        "FastAPI enables high-performance async APIs.",
        "OpenTelemetry provides vendor-neutral observability.",
        "LLM observability requires tracing prompts and tokens.",
    ]


def build_prompt(query: str, documents: List[str]) -&gt; str:
    """
    Construct the final prompt from retrieved documents and the user query.
    Prompt construction is kept separate so it can be observed or modified
    independently if needed (for example, to measure prompt assembly latency).
    """
    context = "\n".join(documents)
    return f"""
Context:
{context}

Question:
{query}
"""


class LLMResponse:
    """
    Minimal abstraction for an LLM response.
    This keeps the example self-contained while still allowing us to attach
    token usage and other metadata for observability.
    """

    def __init__(self, text: str, prompt_tokens: int, completion_tokens: int):
        self.text = text
        self.prompt_tokens = prompt_tokens
        self.completion_tokens = completion_token
    
    @property
    def total_tokens(self) -&gt; int:
        return self.prompt_tokens + self.completion_tokens

async def call_llm(prompt: str) -&gt; LLMResponse:
    """
    Simulate an LLM API call.
    In a real implementation, this would call OpenAI, Anthropic, or another
    provider. The artificial delay represents model latency.
    """
    await asyncio.sleep(0.2)  # Simulate inference time
    response_text = "FastAPI and OpenTelemetry enable end-to-end LLM observability."
    # Token count is approximated here for demonstration purposes.
    prompt_tokens = len(prompt.split())
    completion_tokens = len(response_text.split())
    return LLMResponse(response_text, prompt_tokens, completion_tokens)


def summarize_response(response: LLMResponse) -&gt; str:
    """
    Example post-processing step.
    Post-processing is separated into its own phase so any additional latency
    or errors are not incorrectly attributed to the LLM itself.
    """
    return response.text


# Observable FastAPI endpoint
@app.post("/query")
async def rag_query(request: Request, query: str):
    """
    Handle a single RAG-style request with explicit OpenTelemetry spans.
    This endpoint demonstrates how to create one trace per request, with child
    spans for retrieval, LLM invocation, and post-processing.
    """

    # Create a top-level span for the HTTP request.
    # Even if FastAPI auto-instrumentation is enabled, defining this explicitly
    # allows us to attach domain-specific metadata.
    with tracer.start_as_current_span("http.request") as http_span:
        http_span.set_attribute("http.method", "POST")
        http_span.set_attribute("http.route", "/query")

        # Retrieval phase
        # This span isolates the retrieval step so that relevance issues can be
        # debugged independently of LLM behavior.
        with tracer.start_as_current_span("rag.retrieval") as retrieval_span:
            retrieval_span.set_attribute("rag.top_k", 5)
            retrieval_span.set_attribute("rag.similarity_threshold", 0.8)
            documents = await retrieve_documents(query)

            # Record how many documents were returned.
            # This is a key signal when diagnosing hallucinations
            # or missing context in the final response.
            retrieval_span.set_attribute(
                "rag.documents_returned",
                len(documents),
            )

        # LLM invocation phase
        # This span wraps the actual LLM call and is the primary anchor for
        # latency, cost, and prompt-related analysis.
        with tracer.start_as_current_span("llm.call") as llm_span:
            llm_span.set_attribute("llm.provider", "example")
            llm_span.set_attribute("llm.model", "example-llm")
            llm_span.set_attribute("llm.temperature", 0.7)
            llm_span.set_attribute("llm.prompt_template_id", "rag_v1")

            # Build the final prompt using retrieved context.
            # The raw prompt is intentionally not stored as a span attribute.
            prompt = build_prompt(query, documents)
            
            # Prompt metadata
            prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()
            llm_span.set_attribute("llm.prompt_hash", prompt_hash)
            llm_span.set_attribute("llm.prompt_length", len(prompt))

            response = await call_llm(prompt)

            # Hash the response instead of storing raw text.
            # This allows correlation across traces without exposing content.
            response_hash = hashlib.sha256(
                response.text.encode()
            ).hexdigest()
            llm_span.set_attribute("llm.response_hash", response_hash)

            # Record token usage to enable cost attribution
            # and capacity planning.
            llm_span.set_attribute("llm.usage.prompt_tokens", response.prompt_tokens)
            llm_span.set_attribute("llm.usage.completion_tokens", response.completion_tokens)
            llm_span.set_attribute("llm.usage.total_tokens", response.total_tokens)
            
            # example price per token
            estimated_cost = response.total_tokens * 0.000002
            llm_span.set_attribute("llm.cost_estimated_usd", estimated_cost)

        # Post-processing phase
        # Any transformation after the LLM response is captured here,
        # ensuring inference latency is not overstated.
        with tracer.start_as_current_span("llm.postprocess") as post_span:
            summary = summarize_response(response)
            post_span.set_attribute(
                "llm.summary_length",
                len(summary),
            )

    # Return the final response to the client.
    # All spans above belong to the same distributed trace.
    return {"summary": summary}
</code></pre>
<p>Before examining the full code example, it helps to understand how the instrumentation relates to the observability principles described earlier in this article.</p>
<p>The goal of the example is not simply to show how to create spans, but to demonstrate how a single user request can be represented as a structured trace containing meaningful metadata about each stage of the LLM pipeline.</p>
<p>At a high level, the code follows three key design ideas:</p>
<ol>
<li><p>One trace per user request</p>
</li>
<li><p>One span per logical LLM workflow stage</p>
</li>
<li><p>Semantic attributes attached to spans for debugging, cost tracking, and analysis</p>
</li>
</ol>
<p>Each of these concepts directly corresponds to the observability practices discussed earlier.</p>
<h3 id="heading-top-level-request-span">Top-Level Request Span</h3>
<p>The FastAPI endpoint begins by creating a top-level span called <code>http.request</code>. This span represents the entire lifecycle of the incoming request and serves as the root span for the trace.</p>
<pre><code class="language-python">with tracer.start_as_current_span("http.request") as http_span:
</code></pre>
<p>Although FastAPI can generate HTTP spans automatically through OpenTelemetry auto-instrumentation, explicitly creating this span allows the application to attach domain-specific metadata such as route names or user identifiers.</p>
<p>Attributes such as the HTTP method and route are attached here:</p>
<pre><code class="language-python">http_span.set_attribute("http.method", "POST")
http_span.set_attribute("http.route", "/query")
</code></pre>
<p>This ensures that every trace can be easily filtered by endpoint when analyzing production traffic.</p>
<h3 id="heading-retrieval-span">Retrieval Span</h3>
<p>The next span captures the retrieval phase of the RAG pipeline:</p>
<pre><code class="language-python">with tracer.start_as_current_span("rag.retrieval") as retrieval_span:
</code></pre>
<p>This span isolates the vector search or knowledge retrieval step from the rest of the pipeline. If users report irrelevant answers, engineers can inspect this span to determine whether the issue originates from poor retrieval results rather than model behavior.</p>
<p>Several semantic attributes are attached here:</p>
<ul>
<li><p><code>rag.top_k</code> – number of documents requested</p>
</li>
<li><p><code>rag.similarity_threshold</code> – similarity cutoff used for filtering results</p>
</li>
<li><p><code>rag.documents_returned</code> – number of documents actually retrieved</p>
</li>
</ul>
<p>These attributes align with the RAG observability signals discussed in the earlier section of the article.</p>
<h3 id="heading-llm-invocation-span">LLM Invocation Span</h3>
<p>The most important span in the trace is the <code>llm.call</code> span, which wraps the actual model invocation.</p>
<pre><code class="language-python">with tracer.start_as_current_span("llm.call") as llm_span:
</code></pre>
<p>This span captures the latency, configuration, and token usage associated with the LLM request. In production systems, it becomes the primary location for analyzing model behavior and cost.</p>
<p>Key attributes recorded in this span include:</p>
<ul>
<li><p><code>llm.provider</code> – the model provider (OpenAI, Anthropic, etc.)</p>
</li>
<li><p><code>llm.model</code> – the specific model version</p>
</li>
<li><p><code>llm.temperature</code> – sampling parameter controlling response randomness</p>
</li>
<li><p><code>llm.prompt_template_id</code> – identifier for the prompt template used</p>
</li>
</ul>
<p>These attributes make it possible to correlate changes in model configuration with downstream quality or cost changes.</p>
<h3 id="heading-prompt-handling-and-privacy">Prompt Handling and Privacy</h3>
<p>Instead of storing the full prompt or response text directly in the trace, the example demonstrates a safer practice: hashing sensitive data.</p>
<pre><code class="language-python">response_hash = hashlib.sha256(response.text.encode()).hexdigest()
</code></pre>
<p>The resulting hash is stored as a span attribute:</p>
<pre><code class="language-python">llm_span.set_attribute("llm.response_hash", response_hash)
</code></pre>
<p>This approach allows engineers to correlate repeated responses across traces without exposing potentially sensitive content in observability systems.</p>
<h3 id="heading-token-usage-tracking">Token Usage Tracking</h3>
<p>The <code>llm.call</code> span also records token usage:</p>
<pre><code class="language-python">llm_span.set_attribute(
    "llm.usage.total_tokens",
    response.total_tokens
)
</code></pre>
<p>Capturing token usage at the span level is critical for monitoring cost and efficiency, since token consumption directly determines billing for most LLM providers.</p>
<h3 id="heading-post-processing-span">Post-Processing Span</h3>
<p>Finally, the example includes a <code>llm.postprocess</code> span:</p>
<pre><code class="language-python">with tracer.start_as_current_span("llm.postprocess") as post_span:
</code></pre>
<p>This span represents any transformation applied after the model generates its response. Separating post-processing from the LLM call ensures that additional latency — such as formatting, filtering, or validation — is not incorrectly attributed to the model itself.</p>
<p>An attribute such as response length is recorded here:</p>
<pre><code class="language-python">post_span.set_attribute("llm.summary_length", len(summary))
</code></pre>
<p>This can be useful when diagnosing issues such as unexpectedly short or truncated outputs.</p>
<h3 id="heading-how-the-spans-form-a-complete-trace">How the Spans Form a Complete Trace</h3>
<p>When the request finishes, all spans belong to the same distributed trace:</p>
<pre><code class="language-plaintext">http.request
 ├── rag.retrieval
 ├── llm.call
 └── llm.postprocess
</code></pre>
<p>This hierarchy reflects the logical workflow of a retrieval-augmented LLM system. Because each span contains structured metadata, engineers can quickly answer questions such as:</p>
<ul>
<li><p>Was the latency caused by retrieval or model inference?</p>
</li>
<li><p>How many documents influenced the prompt?</p>
</li>
<li><p>Which model configuration produced the response?</p>
</li>
<li><p>How many tokens were consumed?</p>
</li>
<li><p>Was the response post-processed or truncated?</p>
</li>
</ul>
<p>This structured trace design is what transforms observability from simple monitoring into a practical debugging and optimization tool for LLM systems.</p>
<h2 id="heading-semantic-attributes-best-practices-for-llm-observability">Semantic Attributes: Best Practices for LLM Observability</h2>
<p>The goal is not to capture every possible detail, but to record the minimal set of stable, high-signal attributes that enable effective debugging, cost control, and quality analysis in production. Poor attribute design leads to noisy traces, privacy risks, and dashboards that are impossible to reason about.</p>
<h3 id="heading-prompt-response-and-model-metadata">Prompt, Response, and Model Metadata​</h3>
<p>Storing raw prompts is often unsafe and expensive, so it is better to record minimal, structured metadata instead. In practice, this means attaching a stable template identifier with <code>llm.prompt_template_id</code>, a hashed version of the final prompt using <code>llm.prompt_hash</code> (to avoid storing raw text), and a size indicator such as <code>llm.prompt_length</code>, which captures the number of tokens or characters.</p>
<p>You should also always record key inference parameters: <code>llm.provider</code> (for example, "openai" or "anthropic"), <code>llm.model</code> (for example, "gpt-4.1"), <code>llm.temperature</code> and <code>llm.top_p</code> (sampling parameters), <code>llm.max_tokens</code> (the maximum tokens allowed), and <code>llm.stream</code> to indicate whether streaming was enabled, while staying within your organization’s privacy and compliance requirements.</p>
<pre><code class="language-python">
with tracer.start_as_current_span("llm.call") as llm_span:
            llm_span.set_attribute("llm.provider", "example")
            llm_span.set_attribute("llm.model", "example-llm")
            llm_span.set_attribute("llm.temperature", 0.7)
            llm_span.set_attribute("llm.top_p", 0.9)
            llm_span.set_attribute("llm.max_tokens", 512)
            llm_span.set_attribute("llm.stream", False)
            llm_span.set_attribute("llm.prompt_template_id", "rag_v1")

            # Build the final prompt using retrieved context.
            # The raw prompt is intentionally not stored as a span attribute.
            prompt = build_prompt(query, documents)
            
            # Prompt metadata
            prompt_hash = hashlib.sha256(prompt.encode()).hexdigest()
            llm_span.set_attribute("llm.prompt_hash", prompt_hash)
            llm_span.set_attribute("llm.prompt_length", len(prompt))
</code></pre>
<h3 id="heading-token-usage-and-cost-why-this-matters-in-practice">Token Usage and Cost (Why This Matters in Practice)</h3>
<p>Token usage is one of the most common blind spots in LLM systems. Many teams monitor latency and error rates but discover runaway costs only after invoices spike. Because token consumption varies significantly by prompt structure, retrieved context, and model configuration, it must be captured explicitly at the span level.​</p>
<p>The most important practice is to record token usage at the end of the LLM span, once the model has completed inference. This ensures that the values reflect the full request rather than partial or streamed output.</p>
<p>At minimum, capture the attributes:​<code>llm.usage.prompt_tokens</code> ,<code>llm.usage.completion_tokens</code> and <code>llm.usage.total_tokens</code>​.</p>
<pre><code class="language-python">def __init__(self, text: str, prompt_tokens: int, completion_tokens: int):
        self.text = text
        self.prompt_tokens = prompt_tokens
        self.completion_tokens = completion_token
    
    @property
    def total_tokens(self) -&gt; int:
        return self.prompt_tokens + self.completion_tokens

async def call_llm(prompt: str) -&gt; LLMResponse:
    """
    Simulate an LLM API call.
    In a real implementation, this would call OpenAI, Anthropic, or another
    provider. The artificial delay represents model latency.
    """
    await asyncio.sleep(0.2)  # Simulate inference time
    response_text = "FastAPI and OpenTelemetry enable end-to-end LLM observability."
    # Token count is approximated here for demonstration purposes.
    prompt_tokens = len(prompt.split())
    completion_tokens = len(response_text.split())
    return LLMResponse(response_text, prompt_tokens, completion_tokens)
</code></pre>
<p>These values allow you to distinguish between requests that are expensive because of large prompts (often caused by excessive retrieval or poor prompt construction) versus those that are expensive because of long model-generated outputs.</p>
<p>*Where possible, also attach an estimated cost:*​ <code>llm.cost_estimated_usd</code>​</p>
<pre><code class="language-python">    # example price per token
    estimated_cost = response.total_tokens * 0.000002
    llm_span.set_attribute("llm.cost_estimated_usd", estimated_cost)
</code></pre>
<p>This value is typically derived by multiplying token counts by the model's published pricing. Even if the estimate is approximate, it enables powerful analysis. For example, you can identify which endpoints, prompt templates, or user flows are responsible for the highest cumulative cost, rather than relying on coarse, account-level billing dashboards.</p>
<p>Once spans carry the right attributes, the next step is to connect them to output quality, not just system health.</p>
<h2 id="heading-evaluation-hooks-inside-traces">Evaluation Hooks Inside Traces</h2>
<p>This section describes an additional pattern you can layer on top of the core instrumentation in this guide. It is optional and not implemented in the sample code, but it shows how to attach quality signals directly to your traces.</p>
<p>Observability is not just about whether the system stayed up, it is also about whether the model produced a useful answer. Evaluation hooks inside traces let you attach lightweight quality signals directly to the same spans you use for latency and cost.</p>
<p>Inline evaluations are the simplest approach. You can run quick checks synchronously and record the results as span attributes, such as <code>llm.eval.passed</code> for a simple boolean check, <code>llm.eval.relevance_score</code> for an optional numerical score, or flags like <code>llm.eval.hallucination_detected</code> and <code>llm.eval.refusal_detected</code>. These attributes travel with the trace, so you can filter and aggregate on them in your observability backend just like any other field.</p>
<p>For higher accuracy, you can introduce model-based evaluation as a separate step. In this pattern, an evaluator LLM runs asynchronously on the original prompt and response, and its work is captured in a child span (for example, <code>llm.eval</code>) that shares the same trace ID as the main <code>llm.call</code> span. You then attach scores such as relevance, faithfulness, or toxicity to that evaluation span.</p>
<p>Because the evaluation span shares the same trace ID, you can correlate quality regressions with changes in prompts or retrieval.</p>
<h2 id="heading-exporting-and-visualizing-traces-where-this-fits-with-vendor-tooling">Exporting and Visualizing Traces (Where This Fits with Vendor Tooling)</h2>
<p>This code-first observability design is vendor-agnostic. Once traces are emitted using OpenTelemetry, they can be exported to different backends without changing instrumentation.</p>
<p>General-purpose tracing systems like Jaeger and Grafana Tempo help engineers debug latency, errors, and request flow across retrieval, prompting, and model calls, answering how the system behaved. LLM-focused platforms such as Arize Phoenix use the same data but add model-specific insights like prompt clustering, token analysis, and quality correlation.</p>
<p>Because instrumentation stays OpenTelemetry-native, you maintain full control over attributes and trace structure while still using vendor dashboards, and you can switch backends as your needs evolve without touching the application code.</p>
<h2 id="heading-operational-patterns-and-anti-patterns">Operational Patterns and Anti-Patterns</h2>
<p>Effective LLM observability requires disciplined practices. High-volume systems should sample traces to limit overhead, and prompts or responses should be hashed by default to reduce storage and privacy risk. Traces must be treated as production data, with proper access control and retention policies.</p>
<p>Common pitfalls include relying only on vendor SDK traces, logging prompts without trace correlation, or ignoring evaluation signals. These issues fragment visibility and hide quality regressions, especially when observability focuses only on agents instead of full application context.</p>
<h2 id="heading-extending-the-system">Extending the System</h2>
<p>Once traces are reliable, they support advanced capabilities. Metrics like p95 latency can be derived from spans, logs can be linked using trace IDs, and historical traces can power offline evaluation or prompt testing.​</p>
<p>By following OpenTelemetry conventions, the observability stack also stays aligned with emerging LLM semantic standards, keeping the system flexible and future-proof.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>End-to-end LLM observability is not achieved by installing another agent. It is achieved through intentional span design, meaningful semantic attributes, and, where needed, lightweight evaluation hooks.​</p>
<p>By treating LLM calls as first-class operations within distributed traces, you gain faster debugging, controlled costs, safer deployments, and measurable quality improvements. The backend — Jaeger, Tempo, Phoenix — is interchangeable. The instrumentation strategy is not.​</p>
<p>A well-designed trace is the most valuable artifact in a production LLM system.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Learn how to fine-tune LLMs in 12 hours ]]>
                </title>
                <description>
                    <![CDATA[ The goal isn't just to train a model; it's to build a system that understands your specific data as well as you do. We just posted a massive, 12-hour course on the freeCodeCamp.org YouTube channel des ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-how-to-fine-tune-llms-in-12-hours/</link>
                <guid isPermaLink="false">69b191d16c896b0519a4b11f</guid>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Wed, 11 Mar 2026 16:01:21 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/607e7b69-615d-4e07-a196-ce39b425a93b.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The goal isn't just to train a model; it's to build a system that understands your specific data as well as you do.</p>
<p>We just posted a massive, 12-hour course on the <a href="http://freeCodeCamp.org"><strong>freeCodeCamp.org</strong></a> YouTube channel designed to turn you from an AI consumer into an LLM architect.</p>
<p>While massive models like Llama 3, Gemini, and GPT-4 are impressive out of the box, their true power is unlocked when they are tailored to specific domains. This course is a deep dive into the modern LLM ecosystem, teaching you how to take these giants and make them work for your specific needs.</p>
<p>The course is structured into four major sections:</p>
<ul>
<li><p>The Foundations of PEFT: Learn why full fine-tuning is often overkill. You will learn about Parameter-Efficient Fine-Tuning (PEFT), and master techniques like LoRA and QLoRA to train models on consumer hardware.</p>
</li>
<li><p>Advanced Alignment: You’ll learn about Reinforcement Learning from Human Feedback (RLHF) and the increasingly popular Direct Preference Optimization (DPO) to align models with human intent.</p>
</li>
<li><p>High-Performance Tooling: Get hands-on with the fastest tools in the industry. The course covers Unsloth (for 2x faster training), Axolotl, and the Llama Factory project for streamlined workflows.</p>
</li>
<li><p>Enterprise &amp; Multimodal AI: Beyond text, the course explores Vision Transformers (ViT), multimodal architectures (Image, Video, Audio), and how to leverage enterprise APIs from OpenAI and Google Cloud Vertex AI.</p>
</li>
</ul>
<p>Watch the full course on <a href="https://youtu.be/CcrC5zSv1iA">the freeCodeCamp.org YouTube channel</a> (12-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/CcrC5zSv1iA" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready Voice Agent Architecture with WebRTC ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, you'll build a production-ready voice agent architecture: a browser client that streams audio over WebRTC (Web Real-Time Communication), a backend that mints short-lived session toke ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-production-ready-voice-agents/</link>
                <guid isPermaLink="false">69ab2f260bca1a3976458b2a</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Accessibility ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Voice ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Nataraj Sundar ]]>
                </dc:creator>
                <pubDate>Fri, 06 Mar 2026 19:46:46 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/c61b4358-66d9-434d-8555-d8921313e573.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, you'll build a production-ready voice agent architecture: a browser client that streams audio over WebRTC (Web Real-Time Communication), a backend that mints short-lived session tokens, an agent runtime that orchestrates speech and tools safely, and generates post-call artifacts for downstream workflows.</p>
<p>This article is intentionally vendor-neutral. You can implement these patterns using any AI voice platform that supports WebRTC (directly or via an SFU, selective forwarding unit) and server-side token minting. The goal is to help you ship a voice agent architecture that is secure, observable, and operable in production.</p>
<blockquote>
<p><em>Disclosure: This article reflects my personal views and experience. It does not represent the views of my employer or any vendor mentioned.</em></p>
</blockquote>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#how-to-avoid-common-production-failures-in-voice-agents">How to Avoid Common Production Failures in Voice Agents</a></p>
</li>
<li><p><a href="#how-to-design-a-latency-budget-for-a-real-time-voice-agent">How to Design a Latency Budget for a Real-Time Voice Agent</a></p>
</li>
<li><p><a href="#production-voice-agent-architecture-vendor-neutral">Production Voice Agent Architecture (Vendor-Neutral)</a></p>
<ul>
<li><p><a href="#step-0-set-up-the-project">Step 0: Set Up the Project</a></p>
</li>
<li><p><a href="#step-1-keep-credentials-server-side">Step 1: Keep Credentials Server-side</a></p>
</li>
<li><p><a href="#step-2-build-a-backend-token-endpoint">Step 2: Build a Backend Token Endpoint</a></p>
</li>
<li><p><a href="#step-3-connect-from-the-web-client-webrtc--sfu">Step 3: Connect from the Web Client (WebRTC + SFU)</a></p>
</li>
<li><p><a href="#step-4-add-client-actions-agent-suggests-app-executes">Step 4: Add Client Actions (Agent Suggests, App Executes)</a></p>
</li>
<li><p><a href="#step-5-add-tool-integrations-safely">Step 5: Add Tool Integrations Safely</a></p>
</li>
<li><p><a href="#step-6-add-post-call-processing-where-durable-value-appears">Step 6: Add post-call processing (where durable value appears)</a></p>
</li>
</ul>
</li>
<li><p><a href="#production-readiness-checklist">Production readiness checklist</a></p>
</li>
<li><p><a href="#closing">Closing</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end, you'll have:</p>
<ul>
<li><p>A web client that streams microphone audio and plays agent audio.</p>
</li>
<li><p>A backend token endpoint that keeps credentials server-side.</p>
</li>
<li><p>A safe coordination channel between the agent and the application.</p>
</li>
<li><p>Structured messages between the application and the agent.</p>
</li>
<li><p>A production checklist for security, reliability, observability, and cost control.</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with:</p>
<ul>
<li><p>JavaScript or TypeScript</p>
</li>
<li><p>Node.js 18+ (so <code>fetch</code> works server-side) and an HTTP framework (Express in examples)</p>
</li>
<li><p>Browser microphone permissions</p>
</li>
<li><p>Basic WebRTC concepts (high level is fine)</p>
</li>
</ul>
<h2 id="heading-tldr">TL;DR</h2>
<p>A <strong>production-ready voice agent</strong> needs:</p>
<ul>
<li><p>A <strong>server-side token service</strong> (no secrets in the browser)</p>
</li>
<li><p>A <strong>real-time media plane</strong> (WebRTC) for low-latency audio</p>
</li>
<li><p>A <strong>data channel</strong> for structured messages between your app and the agent</p>
</li>
<li><p><strong>Tool guardrails</strong> (allowlists, confirmations, timeouts, audit logs)</p>
</li>
<li><p><strong>Post-call processing</strong> (summary, actions, CRM (Customer Relationship Management), tickets)</p>
</li>
<li><p><strong>Observability-first</strong> implementation (state transitions + metrics)</p>
</li>
</ul>
<h2 id="heading-how-to-avoid-common-production-failures-in-voice-agents">How to Avoid Common Production Failures in Voice Agents</h2>
<p>If you've operated distributed systems, you've seen most failures happen at boundaries:</p>
<ul>
<li><p>timeouts and partial connectivity</p>
</li>
<li><p>retries that amplify load</p>
</li>
<li><p>unclear ownership between components</p>
</li>
<li><p>missing observability</p>
</li>
<li><p>“helpful automation” that becomes unsafe</p>
</li>
</ul>
<p>Voice agents amplify those risks because:</p>
<p><strong>Latency is User Experience</strong>: A slow agent feels broken. Conversational UX is less forgiving than web UX.</p>
<p><strong>Audio + UI + Tools is a Distributed System</strong>: You coordinate browser audio capture, WebRTC transport, STT (speech-to-text), model reasoning, tool calls, TTS (text-to-speech), and playback buffering. Each stage has different clocks and failure modes.</p>
<p><strong>Security Boundaries are Non-negotiable</strong>: A leaked API key is catastrophic. A tool misfire can trigger real-world side effects.</p>
<p><strong>Debuggability determines whether you can ship</strong>: If you don't log state transitions and capture post-call artifacts, you can't operate or improve the system safely.</p>
<h2 id="heading-how-to-design-a-latency-budget-for-a-real-time-voice-agent">How to Design a Latency Budget for a Real-Time Voice Agent</h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/694ca88d5ac09a5d68c63854/8bb5c6d5-4250-457b-94a2-fcb748050731.png" alt="Latency budget for a real-time voice agent showing mic capture, network RTT, STT, reasoning, tools, TTS, and playback buffering." style="display:block;margin:0 auto" width="5536" height="305" loading="lazy">

<p>Conversations have a “feel.” That feel is mostly latency.</p>
<p>A practical guideline:</p>
<ul>
<li><p>Under <strong>~200ms</strong> feels instant</p>
</li>
<li><p><strong>300–500ms</strong> feels responsive</p>
</li>
<li><p>Over <strong>~700ms</strong> feels broken</p>
</li>
</ul>
<p>Your end-to-end latency is the sum of mic capture, network RTT (round-trip time), STT, reasoning, tool execution, TTS, and playback buffering. Budget for it explicitly or you’ll ship a technically correct system that users perceive as unintelligent.</p>
<h2 id="heading-how-to-design-a-production-voice-agent-architecture-vendor-neutral">How to Design a Production Voice Agent Architecture (Vendor-Neutral)</h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/694ca88d5ac09a5d68c63854/f0411ddc-d3fb-48e4-be72-37d9765bf0a7.png" alt="Production-ready voice agent architecture showing web client, token service, WebRTC real-time plane, agent runtime, tool layer, and post-call processing." style="display:block;margin:0 auto" width="7418" height="1961" loading="lazy">

<p>A scalable <strong>voice agent architecture</strong> typically has these layers:</p>
<ol>
<li><p><strong>Web client</strong>: mic capture, audio playback, UI state</p>
</li>
<li><p><strong>Token service</strong>: short-lived session tokens (secrets stay server-side)</p>
</li>
<li><p><strong>Real-time plane</strong>: WebRTC media + a data channel</p>
</li>
<li><p><strong>Agent runtime</strong>: STT → reasoning → TTS, plus tool orchestration</p>
</li>
<li><p><strong>Tool layer</strong>: external actions behind safety controls</p>
</li>
<li><p><strong>Post-call processor</strong>: summary + structured outputs after the session ends</p>
</li>
</ol>
<p>This separation makes failure domains and trust boundaries explicit.</p>
<h2 id="heading-step-0-set-up-the-project">Step 0: Set Up the Project</h2>
<p>Create a new project directory:</p>
<pre><code class="language-shell">mkdir voice-agent-app
cd voice-agent-app
npm init -y
npm pkg set type=module
npm pkg set scripts.start="node server.js"
</code></pre>
<p>Install dependencies:</p>
<pre><code class="language-shell">npm install express dotenv
</code></pre>
<p>Create this folder structure:</p>
<pre><code class="language-plaintext">voice-agent-app/
├── server.js
├── .env
└── public/
    ├── index.html
    └── client.js
</code></pre>
<p>Add a <code>.env</code> file:</p>
<pre><code class="language-shell">VOICE_PLATFORM_URL=https://your-provider.example
VOICE_PLATFORM_API_KEY=your_api_key_here
</code></pre>
<p>Now you’re ready to implement each part of the system.</p>
<h2 id="heading-step-1-keep-credentials-server-side">Step 1: Keep Credentials Server-side</h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/694ca88d5ac09a5d68c63854/d522fdf2-bb96-4531-b4ff-3a364336178c.png" alt="Security trust boundary diagram showing browser as untrusted zone and backend/tooling as trusted zone with secrets server-side." style="display:block;margin:0 auto" width="4567" height="2388" loading="lazy">

<p>Treat every API key like production credentials:</p>
<ul>
<li><p>store it in environment variables or a secrets manager</p>
</li>
<li><p>rotate it if exposed</p>
</li>
<li><p>never embed it in browser or mobile apps</p>
</li>
<li><p>avoid logging secrets (log only a short suffix if necessary)</p>
</li>
</ul>
<p>Even if a vendor supports CORS, the browser is not a safe place for long-lived credentials.</p>
<h2 id="heading-step-2-build-a-backend-token-endpoint">Step 2: Build a Backend Token Endpoint</h2>
<p>Your backend should:</p>
<ul>
<li><p>authenticate the user</p>
</li>
<li><p>mint a short-lived session token using your platform API</p>
</li>
<li><p>return only what the client needs (URL + token + expiry)</p>
</li>
</ul>
<h3 id="heading-create-serverjs-nodejs-express">Create server.js (Node.js + Express)</h3>
<pre><code class="language-javascript">import express from "express";
import dotenv from "dotenv";
import path from "path";
import { fileURLToPath } from "url";

dotenv.config();

const app = express();
app.use(express.json());

// Serve the web client from /public
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
app.use(express.static(path.join(__dirname, "public")));

const VOICE_PLATFORM_URL = process.env.VOICE_PLATFORM_URL;
const VOICE_PLATFORM_API_KEY = process.env.VOICE_PLATFORM_API_KEY;

app.post("/api/voice-token", async (req, res) =&gt; {
  res.setHeader("Cache-Control", "no-store");

  try {
    if (!VOICE_PLATFORM_URL || !VOICE_PLATFORM_API_KEY) {
      return res.status(500).json({
        error: "Missing VOICE_PLATFORM_URL or VOICE_PLATFORM_API_KEY in .env",
      });
    }

    // TODO: Authenticate the caller before minting tokens.

    const r = await fetch(`${VOICE_PLATFORM_URL}/api/v1/token`, {
      method: "POST",
      headers: {
        "X-API-Key": VOICE_PLATFORM_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ participant_name: "Web User" }),
    });

    if (!r.ok) {
      const detail = await r.text().catch(() =&gt; "");
      return res.status(r.status).json({ error: "Token request failed", detail });
    }

    const data = await r.json();

    res.json({
      rtc_url: data.rtc_url || data.livekit_url,
      token: data.token,
      expires_in: data.expires_in,
    });
  } catch (err) {
    res.status(500).json({ error: "Failed to mint token" });
  }
});

app.listen(3000, () =&gt; console.log("Open http://localhost:3000"));
</code></pre>
<h3 id="heading-run-the-server">Run the server</h3>
<pre><code class="language-shell">npm start
</code></pre>
<p>Then open: <a href="http://localhost:3000">http://localhost:3000</a></p>
<h3 id="heading-how-this-code-works">How this code works</h3>
<ul>
<li><p>You load credentials from environment variables so secrets never enter the browser.</p>
</li>
<li><p>The <code>/api/voice-token</code> endpoint calls the voice platform’s token API.</p>
</li>
<li><p>You return only the <code>rtc_url</code>, <code>token</code>, and expiration time.</p>
</li>
<li><p>The browser never sees the API key.</p>
</li>
<li><p>If the provider returns an error, you forward a structured error response.</p>
</li>
</ul>
<h3 id="heading-production-notes"><strong>Production Notes</strong></h3>
<ul>
<li><p>rate-limit /api/voice-token (cost + abuse control)</p>
</li>
<li><p>instrument token mint latency and error rate</p>
</li>
<li><p>keep TTL short and handle refresh/reconnect</p>
</li>
<li><p>return minimal fields</p>
</li>
</ul>
<h2 id="heading-step-3-connect-from-the-web-client-webrtc-sfu">Step 3: Connect from the Web Client (WebRTC + SFU)</h2>
<p>In this step, you'll build a minimal web UI that:</p>
<ul>
<li><p>Requests a short-lived token from your backend</p>
</li>
<li><p>Connects to a real-time WebRTC room (often via an SFU)</p>
</li>
<li><p>Plays the agent's audio track</p>
</li>
<li><p>Captures and publishes microphone audio</p>
</li>
</ul>
<h3 id="heading-create-publicindexhtml">Create <code>public/index.html</code></h3>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width,initial-scale=1" /&gt;
    &lt;title&gt;Voice Agent Demo&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Voice Agent Demo&lt;/h1&gt;

    &lt;button id="startBtn"&gt;Start Call&lt;/button&gt;
    &lt;button id="endBtn" disabled&gt;End Call&lt;/button&gt;

    &lt;p id="status"&gt;Idle&lt;/p&gt;

    &lt;script type="module" src="/client.js"&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h3 id="heading-create-publicclientjs">Create <code>public/client.js</code></h3>
<p>Note: This uses a LiveKit-style client SDK to demonstrate the pattern. If you're using a different provider, swap this import and the connect/publish calls for your provider's WebRTC client.</p>
<pre><code class="language-javascript">import { Room, RoomEvent, Track } from "https://unpkg.com/livekit-client@2.10.1/dist/livekit-client.esm.mjs";

const startBtn = document.getElementById("startBtn");
const endBtn = document.getElementById("endBtn");
const statusEl = document.getElementById("status");

let room = null;
let intentionallyDisconnected = false;
let audioEls = [];

function setStatus(text) {
  statusEl.textContent = text;
}

function detachAllAudio() {
  for (const el of audioEls) {
    try { el.pause?.(); } catch {}
    el.remove();
  }
  audioEls = [];
}

async function mintToken() {
  const res = await fetch("/api/voice-token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ participant_name: "Web User" }),
    cache: "no-store",
  });

  if (!res.ok) {
    const detail = await res.text().catch(() =&gt; "");
    throw new Error(`Token request failed: ${detail || res.status}`);
  }

  const { rtc_url, token } = await res.json();
  if (!rtc_url || !token) throw new Error("Token response missing rtc_url or token");
  return { rtc_url, token };
}

function wireRoomEvents(r) {
  // 1) Play the agent audio track when subscribed
  r.on(RoomEvent.TrackSubscribed, (track) =&gt; {
    if (track.kind !== Track.Kind.Audio) return;

    const el = track.attach();
    audioEls.push(el);
    document.body.appendChild(el);

    // Autoplay restrictions vary by browser/device.
    el.play?.().catch(() =&gt; {
      setStatus("Connected (audio may be blocked — click the page to enable)");
    });
  });

  // 2) Reconnect on disconnect (token expiry often shows up this way)
  r.on(RoomEvent.Disconnected, async () =&gt; {
    if (intentionallyDisconnected) return;
    setStatus("Disconnected (reconnecting...)");
    await attemptReconnect();
  });
}

async function connectOnce() {
  const { rtc_url, token } = await mintToken();

  const r = new Room();
  wireRoomEvents(r);

  await r.connect(rtc_url, token);

  // Mic permission + publish mic
  try {
    await r.localParticipant.setMicrophoneEnabled(true);
  } catch {
    try { r.disconnect(); } catch {}
    throw new Error("Microphone access denied. Allow mic permission and try again.");
  }

  return r;
}

async function startCall() {
  if (room) return;

  intentionallyDisconnected = false;
  setStatus("Connecting...");

  room = await connectOnce();

  setStatus("Connected");
  startBtn.disabled = true;
  endBtn.disabled = false;
}

async function stopCall() {
  intentionallyDisconnected = true;

  try {
    await room?.localParticipant?.setMicrophoneEnabled(false);
  } catch {}

  try {
    room?.disconnect();
  } catch {}

  room = null;
  detachAllAudio();

  setStatus("Disconnected");
  startBtn.disabled = false;
  endBtn.disabled = true;
}

async function attemptReconnect() {
  // Simplified exponential backoff reconnect.
  // In production, add jitter, max attempts, and better error classification.
  const delaysMs = [250, 500, 1000, 2000];

  for (const delay of delaysMs) {
    if (intentionallyDisconnected) return;

    try {
      // Tear down current state before reconnecting
      try { room?.disconnect(); } catch {}
      room = null;
      detachAllAudio();

      await new Promise((r) =&gt; setTimeout(r, delay));

      room = await connectOnce();
      setStatus("Reconnected");
      startBtn.disabled = true;
      endBtn.disabled = false;
      return;
    } catch {
      // keep retrying
    }
  }

  setStatus("Disconnected (reconnect failed)");
  startBtn.disabled = false;
  endBtn.disabled = true;
}

startBtn.addEventListener("click", async () =&gt; {
  try {
    await startCall();
  } catch (err) {
    setStatus(err?.message || "Connection failed");
    startBtn.disabled = false;
    endBtn.disabled = true;
    room = null;
    detachAllAudio();
  }
});

endBtn.addEventListener("click", async () =&gt; {
  await stopCall();
});
</code></pre>
<h3 id="heading-how-this-step-works-and-why-these-details-matter">How this Step works (and why these details matter)</h3>
<ul>
<li><p>The Start button gives you a user gesture so browsers are more likely to allow audio playback.</p>
</li>
<li><p>Mic permission is handled explicitly: if the user denies access, you show a clear error and avoid a half-connected session.</p>
</li>
<li><p>Disconnect cleanup removes audio elements so you don't leak resources across retries.</p>
</li>
<li><p>The reconnect loop demonstrates the production pattern: if a disconnect happens (often due to token expiry or network churn), the client re-mints a token and reconnects.</p>
</li>
</ul>
<p>In the next step, you'll add a structured data-channel handler to safely process agent-suggested “client actions”.</p>
<h3 id="heading-handle-these-explicitly"><strong>Handle These Explicitly</strong></h3>
<h3 id="heading-autoplay-restriction-example">Autoplay Restriction Example</h3>
<p>Add this to <code>index.html</code>:</p>
<pre><code class="language-html">&lt;button id="startBtn"&gt;Start Call&lt;/button&gt;
&lt;button id="endBtn" disabled&gt;End Call&lt;/button&gt;
&lt;div id="status"&gt;&lt;/div&gt;
</code></pre>
<p>In <code>client.js</code>:</p>
<pre><code class="language-javascript">const startBtn = document.getElementById("startBtn");
const endBtn = document.getElementById("endBtn");
const statusEl = document.getElementById("status");

let room;

startBtn.addEventListener("click", async () =&gt; {
  try {
    room = await connectVoice();
    statusEl.textContent = "Connected";
    startBtn.disabled = true;
    endBtn.disabled = false;
  } catch (err) {
    statusEl.textContent = "Connection failed";
  }
});
</code></pre>
<h3 id="heading-microphone-denial">Microphone denial</h3>
<pre><code class="language-javascript">try {
  await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err) {
  statusEl.textContent = "Microphone access denied";
  throw err;
}
</code></pre>
<h3 id="heading-disconnect-cleanup">Disconnect cleanup</h3>
<pre><code class="language-javascript">endBtn.addEventListener("click", () =&gt; {
  if (room) {
    room.disconnect();
    statusEl.textContent = "Disconnected";
    startBtn.disabled = false;
    endBtn.disabled = true;
  }
});
</code></pre>
<h3 id="heading-token-refresh-simplified">Token refresh (simplified)</h3>
<pre><code class="language-javascript">room.on(RoomEvent.Disconnected, async () =&gt; {
  const res = await fetch("/api/voice-token");
  const { rtc_url, token } = await res.json();
  await room.connect(rtc_url, token);
});
</code></pre>
<h2 id="heading-step-4-add-client-actions-agent-suggests-app-executes">Step 4: Add Client Actions (Agent Suggests, App Executes)</h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/694ca88d5ac09a5d68c63854/2304be1c-3451-45f8-ae44-2519fa92c82a.png" alt="Sequence diagram showing agent requesting a client action, app validating allowlist, user confirming, and app executing the side effect." style="display:block;margin:0 auto" width="5895" height="2960" loading="lazy">

<p>A production voice agent often needs to:</p>
<ul>
<li><p>open a runbook/dashboard URL</p>
</li>
<li><p>show a checklist in the UI</p>
</li>
<li><p>request confirmation for an irreversible action</p>
</li>
<li><p>receive structured context (account, region, incident ID)</p>
</li>
</ul>
<p>The key safety rule:</p>
<p><strong>The agent suggests actions. The application validates and executes them.</strong></p>
<p>Use structured messages over the data channel:</p>
<pre><code class="language-json">{
&nbsp;&nbsp;"type": "client_action",
&nbsp;&nbsp;"action": "open_url",
&nbsp;&nbsp;"payload": { "url": "https://internal.example.com/runbook" },
&nbsp;&nbsp;"id": "action_123"
}
</code></pre>
<p><strong>Add guardrails</strong>:</p>
<ul>
<li><p>allowlist permitted actions</p>
</li>
<li><p>validate payload shape</p>
</li>
<li><p>confirmation gates for irreversible actions</p>
</li>
<li><p>idempotency via id</p>
</li>
<li><p>audit logs for every request and outcome</p>
</li>
</ul>
<p>This boundary limits damage from hallucinations or prompt injection.</p>
<pre><code class="language-javascript">// Guardrails: allowlist + validation + idempotency + confirmation

const ALLOWED_ACTIONS = new Set(["open_url", "request_confirm"]);
const EXECUTED_ACTION_IDS = new Set();
const ALLOWED_HOSTS = new Set(["internal.example.com"]);

function parseClientAction(text) {
  let msg;
  try {
    msg = JSON.parse(text);
  } catch {
    return null;
  }

  if (msg?.type !== "client_action") return null;
  if (typeof msg.id !== "string") return null;
  if (!ALLOWED_ACTIONS.has(msg.action)) return null;

  return msg;
}

async function handleClientAction(msg, room) {
  if (EXECUTED_ACTION_IDS.has(msg.id)) return; // idempotency
  EXECUTED_ACTION_IDS.add(msg.id);

  console.log("[client_action]", msg); // audit log (demo)

  if (msg.action === "open_url") {
    const url = msg.payload?.url;
    if (typeof url !== "string") return;

    const u = new URL(url);
    if (!ALLOWED_HOSTS.has(u.host)) {
      console.warn("Blocked navigation to:", u.host);
      return;
    }

    window.open(url, "_blank", "noopener,noreferrer");
    return;
  }

  if (msg.action === "request_confirm") {
    const prompt = msg.payload?.prompt || "Confirm this action?";
    const ok = window.confirm(prompt);

    // Send confirmation back to agent/app
    room.localParticipant.publishData(
  new TextEncoder().encode(
    JSON.stringify({ type: "user_confirmed", id: msg.id, ok })
  ),
  { topic: "client_events", reliable: true }
);
  }
}
</code></pre>
<pre><code class="language-javascript">room.on(RoomEvent.DataReceived, (payload, participant, kind, topic) =&gt; {
  if (topic !== "client_actions") return;

  const text = new TextDecoder().decode(payload);
  const msg = parseClientAction(text);
  if (!msg) return;

  handleClientAction(msg, room);
});
</code></pre>
<h2 id="heading-step-5-add-tool-integrations-safely">Step 5: Add Tool Integrations Safely</h2>
<p>Tools turn a voice agent into automation. Regardless of vendor, enforce these rules:</p>
<ul>
<li><p>timeouts on every tool call</p>
</li>
<li><p>circuit breakers for flaky dependencies</p>
</li>
<li><p>audit logs (inputs, outputs, duration, trace IDs)</p>
</li>
<li><p>explicit confirmation for destructive actions</p>
</li>
<li><p>credentials stored server-side (never in prompts or clients)</p>
</li>
</ul>
<p>If tools fail, degrade gracefully (“I can’t access that system right now, here’s the manual fallback.”). Silence reads as failure.</p>
<p><strong>Create a server-side tool runner (example)</strong></p>
<p>Paste this into <code>server.js</code>:</p>
<pre><code class="language-javascript">const TOOL_ALLOWLIST = {
  get_status: { destructive: false },
  create_ticket: { destructive: true },
};

let failures = 0;
let circuitOpenUntil = 0;

function circuitOpen() {
  return Date.now() &lt; circuitOpenUntil;
}

async function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) =&gt; setTimeout(() =&gt; reject(new Error("timeout")), ms)),
  ]);
}

async function runToolSafely(tool, args) {
  if (circuitOpen()) throw new Error("circuit_open");

  try {
    const result = await withTimeout(Promise.resolve({ ok: true, tool, args }), 2000);
    failures = 0;
    return result;
  } catch (err) {
    failures++;
    if (failures &gt;= 3) circuitOpenUntil = Date.now() + 10_000;
    throw err;
  }
}

app.post("/api/tools/run", async (req, res) =&gt; {
  const { tool, args, user_confirmed } = req.body || {};

  if (!TOOL_ALLOWLIST[tool]) return res.status(400).json({ error: "Tool not allowed" });

  if (TOOL_ALLOWLIST[tool].destructive &amp;&amp; user_confirmed !== true) {
    return res.status(400).json({ error: "Confirmation required" });
  }

  try {
    const started = Date.now();
    const result = await runToolSafely(tool, args);
    console.log("[tool_call]", { tool, ms: Date.now() - started }); // audit log
    res.json({ ok: true, result });
  } catch (err) {
    console.log("[tool_error]", { tool, err: String(err) });
    res.status(500).json({ ok: false, error: "Tool call failed" });
  }
});
</code></pre>
<h2 id="heading-step-6-add-post-call-processing-where-durable-value-appears">Step 6: Add post-call processing (where durable value appears)</h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/694ca88d5ac09a5d68c63854/65d350ff-8f20-489f-b5de-9cd59dda5b8c.png" alt="Post-call processing workflow showing transcript storage, queue/worker, summaries/action items, and integration updates." style="display:block;margin:0 auto" width="5897" height="1095" loading="lazy">

<p>After a call ends, generate structured artifacts:</p>
<ul>
<li><p>summary</p>
</li>
<li><p>action items</p>
</li>
<li><p>follow-up email draft</p>
</li>
<li><p>CRM entry or ticket creation</p>
</li>
</ul>
<p>A production pattern:</p>
<ul>
<li><p>store transcript + metadata</p>
</li>
<li><p>enqueue a background job (queue/worker)</p>
</li>
<li><p>produce outputs as JSON + a human-readable report</p>
</li>
<li><p>apply integrations with retries + idempotency</p>
</li>
<li><p>store a “call report” for audits and incident reviews</p>
</li>
</ul>
<p><strong>Create a post-call webhook endpoint (example)</strong></p>
<p>Paste into <code>server.js</code>:</p>
<pre><code class="language-javascript">app.post("/webhooks/call-ended", async (req, res) =&gt; {
  const payload = req.body;

  console.log("[call_ended]", {
    call_id: payload.call_id,
    ended_at: payload.ended_at,
  });

  setImmediate(() =&gt; processPostCall(payload));
  res.json({ ok: true });
});

function processPostCall(payload) {
  const transcript = payload.transcript || [];
  const summary = transcript.slice(0, 3).map(t =&gt; `- \({t.speaker}: \){t.text}`).join("\n");

  const report = {
    call_id: payload.call_id,
    summary,
    action_items: payload.action_items || [],
    created_at: new Date().toISOString(),
  };

  console.log("[call_report]", report);
}
</code></pre>
<h3 id="heading-test-it-locally">Test it locally</h3>
<pre><code class="language-shell">curl -X POST http://localhost:3000/webhooks/call-ended \
  -H "Content-Type: application/json" \
  -d '{
    "call_id": "call_123",
    "ended_at": "2026-02-26T00:10:00Z",
    "transcript": [
      {"speaker": "user", "text": "I need help resetting my password."},
      {"speaker": "agent", "text": "Sure — I can help with that."}
    ],
    "action_items": ["Send password reset link", "Verify account email"]
  }'
</code></pre>
<h2 id="heading-production-readiness-checklist">Production readiness checklist</h2>
<h3 id="heading-security"><strong>Security</strong></h3>
<ul>
<li><p>no API keys in the browser</p>
</li>
<li><p>strict allowlist for client actions</p>
</li>
<li><p>confirmation gates for destructive actions</p>
</li>
<li><p>schema validation on all inbound messages</p>
</li>
<li><p>audit logging for actions and tool calls</p>
</li>
</ul>
<h3 id="heading-reliability"><strong>Reliability</strong></h3>
<ul>
<li><p>reconnect strategy for expired tokens</p>
</li>
<li><p>timeouts + circuit breakers for tools</p>
</li>
<li><p>graceful degradation when dependencies fail</p>
</li>
<li><p>idempotent side effects</p>
</li>
</ul>
<h3 id="heading-observability"><strong>Observability</strong></h3>
<p>Log state transitions (for example):<br><strong>listening → thinking → speaking → ended</strong></p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/694ca88d5ac09a5d68c63854/a1302294-4338-4a3a-ab0d-c50fd34c117f.png" alt="Voice agent state machine showing listening, thinking, speaking, and ended states for observability." style="display:block;margin:0 auto" width="2217" height="2225" loading="lazy">

<p><strong>Track:</strong></p>
<ul>
<li><p>connect failure rate</p>
</li>
<li><p>end-to-end latency (STT + reasoning + TTS)</p>
</li>
<li><p>tool error rate</p>
</li>
<li><p>reconnect frequency</p>
</li>
</ul>
<h3 id="heading-cost-control"><strong>Cost control</strong></h3>
<ul>
<li><p>rate-limit token minting and sessions</p>
</li>
<li><p>cap max call duration</p>
</li>
<li><p>bound context growth (summarize or truncate)</p>
</li>
<li><p>track per-call usage drivers (STT/TTS minutes, tool calls)</p>
</li>
</ul>
<h2 id="heading-optional-resources">Optional resources</h2>
<h3 id="heading-how-to-try-a-managed-voice-platform-quickly">How to Try a Managed Voice Platform Quickly</h3>
<p>If you want a managed provider to test quickly, you can sign up for a <a href="https://vocalbridgeai.com/">Vocal Bridge account</a> and implement these steps using their token minting + real-time session APIs.</p>
<p>But the core production voice agent architecture in this article is vendor-agnostic. You can replace any component (SFU, STT/TTS, agent runtime, tool layer) as long as you preserve the boundaries: secure token service, real-time media, safe tool execution, and strong observability.</p>
<h3 id="heading-watch-a-full-demo-and-explore-a-complete-reference-repo">Watch a full demo and explore a complete reference repo</h3>
<p>If you'd like to see these patterns working together in a realistic scenario (incident triage), here are two optional resources:</p>
<p>- <strong>Demo video:</strong> <a href="https://youtu.be/TqrtOKd8Zug">Voice-First Incident Triage (end-to-end run)</a><br>This is a hackathon run-through showing client actions, decision boundaries for irreversible actions, and a structured post-call summary.</p>
<p>- <strong>GitHub repo (architecture + design + working code):</strong> <code>https://github.com/natarajsundar/voice-first-incident-triage</code></p>
<p>These links are optional, you can follow the tutorial end-to-end without them.</p>
<h2 id="heading-closing">Closing</h2>
<p>Production-ready voice agents work when you treat them like real-time distributed systems.</p>
<p>Start with the baseline:</p>
<ul>
<li>token service + web client + real-time audio</li>
</ul>
<p>Then layer in:</p>
<ul>
<li><p>controlled client actions</p>
</li>
<li><p>safe tools</p>
</li>
<li><p>post-call automation</p>
</li>
<li><p>observability and cost controls</p>
</li>
</ul>
<p>That’s how you ship a voice agent architecture you can operate. You now have a vendor-neutral reference architecture you can adapt to your stack, with clear trust boundaries, safe tool execution, and operational visibility.</p>
<p>If you’re shipping real-time AI systems, what’s been your biggest production bottleneck so far: <strong>latency, reliability, or tool safety</strong>? I’d love to hear what you’re seeing in the wild. Connect with me on <a href="https://www.linkedin.com/in/natarajsundar/">LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
