Skip to content

Chapter 8: Fine-Tuning Basics - When Prompting Isn't Enough

Theoretical Foundations

The fundamental limitation of any large language model (LLM) lies in its training data. While a model like Llama 3.1 or Phi-3 has ingested a vast portion of the internet, it remains a generalist. It is a "Swiss Army Knife"—excellent at many tasks, but rarely the perfect tool for a specific, specialized job. Prompting is the act of asking the Swiss Army Knife to perform a specific task; fine-tuning is the act of reshaping the blade itself to better suit that task.

The Core Concept: From Static Knowledge to Adaptive Reasoning

To understand fine-tuning, we must first look back at the architectural principles discussed in previous chapters, specifically Transformers.js and the attention mechanism. In earlier chapters, we explored how the model processes input sequences by calculating attention scores—determining how much "focus" each token should pay to every other token in the context window. This is a static mathematical operation based on fixed weights.

Prompting relies entirely on the model's existing parametric knowledge. If you ask a generic model to "summarize this legal contract," it does its best based on the millions of legal documents it saw during pre-training. However, it might use a tone that is too casual, miss specific jurisdictional nuances, or fail to adhere to a strict formatting requirement.

Fine-tuning alters the model's weights (parameters) to specialize in this specific task. It is the difference between hiring a general contractor to build a house (prompting) versus hiring an architect who has spent 20 years designing mid-century modern homes (fine-tuning). The architect uses the same fundamental tools (hammer, nails, blueprints), but their internal intuition—their "weights"—are optimized for that specific style.

The "Why": The Diminishing Returns of Prompting

Why not just use a larger prompt? This is the "Context Window vs. Parameter Update" dilemma.

  1. Context Window Limitations: As models grow, their context windows expand (e.g., 128k tokens). However, stuffing a context window with examples (Few-Shot Prompting) consumes valuable tokens that could be used for the actual input. It is inefficient and often inconsistent.
  2. Generalization vs. Specialization: A model pre-trained on the internet is optimized for next-token prediction across a massive distribution. Fine-tuning shifts the model's probability distribution. It teaches the model to stop generating generic text and start generating domain-specific text.

Analogy: The Web Developer's Workflow Imagine you are building a web application. * Prompting is like using a generic CSS framework (e.g., Bootstrap). It looks good out of the box, but if you want a very specific, unique design language, you have to override styles constantly with inline styles or massive custom CSS files. It works, but it's brittle and heavy. * Fine-tuning is like writing a custom CSS pre-processor or a design system token set. You define the specific variables (weights) once. Now, every component you build adheres to the design language natively. The "inference" (rendering the UI) is faster and more consistent because the core logic has been adapted to the specific domain.

The Mechanics: Parameter-Efficient Fine-Tuning (PEFT)

In the early days of deep learning, fine-tuning meant updating all the weights of a model. For a 70-billion parameter model, this requires massive GPU memory (VRAM)—often exceeding what is available on consumer hardware or even standard cloud instances. This brings us to Parameter-Efficient Fine-Tuning (PEFT), specifically LoRA (Low-Rank Adaptation) and QLoRA (Quantized LoRA).

The LoRA Analogy: The Webpack Proxy In modern web development, particularly with tools like Webpack or Vite, we often use a "proxy" or "overlay" during development. Instead of recompiling the entire application bundle every time we change a line of code, we inject a small patch or use Hot Module Replacement (HMR).

LoRA operates on a similar principle. Instead of updating the massive pre-trained weight matrices (\(W\)), LoRA freezes the original weights and injects trainable "adapter" layers. These adapters are low-rank matrices (\(A\) and \(B\)) that approximate the weight update.

Mathematically, the forward pass of a layer in a transformer looks like this: $\(h = W_0 x + \Delta W x\)$

In standard fine-tuning, we update \(W_0\) to \(W_0 + \Delta W\). In LoRA, we keep \(W_0\) frozen and learn \(\Delta W\) as the product of two smaller matrices, \(A\) and \(B\), where the rank \(r\) is much smaller than the dimension of \(W\): $\(h = W_0 x + (B \cdot A) x\)$

Why is this profound? It allows us to fine-tune a model that is 4x larger than our available VRAM. We load the base model in 4-bit quantization (QLoRA), freeze it, and only train the tiny adapter matrices. The "core" model remains untouched, just like a compiled JavaScript bundle remains untouched during HMR, while the "overlay" (the adapter) carries the specialized knowledge.

The Workflow: Data Curation and Tokenization

Fine-tuning is only as good as the data. This is where the "Garbage In, Garbage Out" principle reigns supreme.

Data Structure: For instruction tuning, data is typically formatted as a JSON structure containing a prompt and a completion.

interface FineTuningExample {
  prompt: string;
  completion: string;
}

The Tokenizer's Role: Recall the tokenizer from earlier chapters. When we fine-tune, we must be careful with the "loss." We generally do not want the model to learn to predict the user's prompt; we only want it to learn to predict the completion. Therefore, we apply a "mask" to the loss function, ignoring the tokens from the prompt portion of the input.

Analogy: The Teacher-Student Interaction Imagine a student (the model) studying for an exam. * Pre-training: The student reads the entire library (the internet). They learn grammar, history, and math, but they don't specialize. * Prompting: You give the student a question and an example answer. They try to mimic it. * Fine-tuning: You give the student a specialized textbook (the dataset). You highlight the specific chapters they need to memorize (masking the loss on irrelevant parts). Over time, the neural pathways in their brain (weights) physically change to prioritize this information.

Integration with Transformers.js and WebGPU

Once the model is fine-tuned (or rather, the adapters are trained), we need to deploy them. In the context of Book 5, we are running this locally in the browser.

The Cold Start Problem: As defined in the context, Cold Start is the delay when loading a model. In a fine-tuned scenario, this is exacerbated because we now have two components to load: 1. The Base Model Weights (e.g., llama-3.1-8b-instruct.q4_0.gguf). 2. The Adapter Weights (a small file, e.g., adapter-v1.bin).

WebGPU Acceleration: WebGPU is the key to making this performant. When we load the model into Transformers.js, we are not just running JavaScript; we are compiling shaders to run on the GPU.

The Merging Process: Before inference, the adapters (\(B \cdot A\)) are often mathematically merged back into the base weights (\(W_0\)). This is like baking the CSS overrides into the final CSS bundle. It eliminates the runtime overhead of calculating the addition of two separate matrices (\(W_0 x + (B \cdot A) x\)) during every forward pass.

However, if we want to switch between multiple fine-tuned tasks (e.g., a legal model and a medical model), we keep them separate and load them dynamically.

Visualizing the Data Flow: The following diagram illustrates the flow of data during a fine-tuned inference request in the browser.

A diagram visualizes the dynamic data flow for a fine-tuned inference request in the browser, showing the separation and loading of distinct legal and medical models.
Hold "Ctrl" to enable pan & zoom

A diagram visualizes the dynamic data flow for a fine-tuned inference request in the browser, showing the separation and loading of distinct legal and medical models.

Performance Optimization and Trade-offs

Fine-tuning introduces a trade-off triangle: Accuracy, Speed, and Memory.

  1. Accuracy: Fine-tuning generally increases accuracy on the target domain. However, "catastrophic forgetting" can occur if the model is over-tuned on a small dataset, causing it to lose general capabilities.
  2. Speed:
    • Inference: If adapters are merged, speed is identical to the base model. If adapters are loaded separately, there is a slight overhead (memory bandwidth).
    • Cold Start: Larger models take longer to load into WebGPU memory. Using quantized models (4-bit or 8-bit) drastically reduces load times and memory footprint.
  3. Memory:
    • Base Model: Requires VRAM proportional to parameter count and precision (e.g., 8B params @ 4-bit = ~4GB).
    • Adapters: Negligible memory usage (often <100MB).

The WebGPU Memory Model: In a browser environment, we cannot simply allocate infinite memory. We must manage the GPU buffer. When loading a fine-tuned model via Transformers.js, we utilize GPUBuffer allocations. The adapter weights are typically smaller and can be uploaded to the GPU as uniform buffers or smaller texture arrays, depending on the implementation.

To summarize the theoretical foundations: 1. Prompting manipulates the input; Fine-tuning manipulates the model. 2. LoRA enables efficient adaptation by learning low-rank updates rather than full weight matrices, analogous to hot-module replacement in web development. 3. Data Curation is the act of teaching the model specific patterns, requiring careful masking of the loss function to avoid learning the prompts themselves. 4. WebGPU provides the parallel processing power to execute these adapted models in the browser, but requires careful memory management to avoid hitting the browser's safety limits.

This foundation sets the stage for the practical application: preparing the dataset and configuring the training loop for a local LLM.

Basic Code Example

In a SaaS or Web App context, relying on an LLM to return a raw string is brittle. If the model changes its phrasing, your parsing logic breaks. The solution is Structured Generation, where we constrain the LLM to produce a specific JSON schema. We will use Zod for runtime validation and type safety, and Ollama (via its REST API) to generate the structured response.

This example demonstrates a "User Intent Classifier" service. It takes a user message (e.g., "I need to reset my password") and forces the LLM to return a structured JSON object identifying the intent and confidence level.

Prerequisites

You must have Ollama running locally with a model loaded (e.g., ollama run llama3.1). We will use the zod library for schema validation.

npm install zod

The TypeScript Implementation

/**
 * @file structured-intent-classifier.ts
 * @description A "Hello World" example of structured JSON output using Ollama and Zod.
 *              This simulates a SaaS backend API endpoint that classifies user intents.
 */

import { z } from 'zod';

// ==========================================
// 1. Define the Zod Schema (The Contract)
// ==========================================

/**
 * Zod schema defining the expected structure of the LLM's response.
 * This ensures the output is strictly typed and validated at runtime.
 * 
 * @example
 * Expected JSON Output:
 * {
 *   "intent": "password_reset",
 *   "confidence": 0.95,
 *   "requires_human_agent": false
 * }
 */
const IntentSchema = z.object({
  intent: z.enum([
    "password_reset", 
    "billing_inquiry", 
    "technical_support", 
    "general_question"
  ]),
  confidence: z.number().min(0).max(1),
  requires_human_agent: z.boolean(),
});

// Infer the TypeScript type from the schema for type safety
type Intent = z.infer<typeof IntentSchema>;

// ==========================================
// 2. Ollama API Client Helper
// ==========================================

/**
 * Sends a prompt to the local Ollama instance and requests a structured JSON response.
 * 
 * @param userMessage - The raw text input from the user.
 * @returns A Promise resolving to the parsed and validated Intent object.
 */
async function classifyIntent(userMessage: string): Promise<Intent> {
  // The System Prompt instructs the model to strictly adhere to the schema.
  // We describe the JSON structure here to guide the model's generation.
  const systemPrompt = `
    You are a precise intent classifier for a SaaS support portal.
    Analyze the user's message and output a JSON object strictly adhering to the following schema:

    {
      "intent": string (one of: "password_reset", "billing_inquiry", "technical_support", "general_question"),
      "confidence": number (between 0 and 1),
      "requires_human_agent": boolean
    }

    Do not output any text before or after the JSON.
  `;

  // The user prompt containing the specific message to classify
  const userPrompt = `Classify this message: "${userMessage}"`;

  try {
    // Call the Ollama API (running locally on port 11434)
    const response = await fetch('http://localhost:11434/api/generate', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        model: 'llama3.1', // Ensure you have this model pulled in Ollama
        prompt: userPrompt,
        system: systemPrompt,
        stream: false, // Important: We need the full response to parse JSON
        format: 'json', // Hint to Ollama to prioritize JSON formatting
      }),
    });

    if (!response.ok) {
      throw new Error(`Ollama API error: ${response.statusText}`);
    }

    const data = await response.json();

    // Ollama returns the response in a 'response' field.
    // Note: Even with 'format: json', the response might be a stringified JSON object.
    let rawContent = data.response;

    // Defensive parsing: Sometimes LLMs wrap JSON in markdown code blocks (```json ... ```)
    // We attempt to extract the JSON object using a regex.
    const jsonMatch = rawContent.match(/```json([\s\S]*?)```/);
    if (jsonMatch) {
      rawContent = jsonMatch[1].trim();
    }

    // Parse the raw string into a JavaScript object
    const parsedJson = JSON.parse(rawContent);

    // ==========================================
    // 3. Validation with Zod
    // ==========================================

    // Validate the parsed object against our Zod schema.
    // If validation fails, Zod throws a detailed error.
    const validatedIntent = IntentSchema.parse(parsedJson);

    console.log("✅ Classification Successful:", validatedIntent);
    return validatedIntent;

  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error("❌ Schema Validation Failed:", error.errors);
      throw new Error("The model output did not match the expected schema.");
    }
    console.error("❌ Error:", error);
    throw error;
  }
}

// ==========================================
// 4. Execution (Simulating a Web App Request)
// ==========================================

/**
 * Main execution block simulating an API route handler in a Next.js or Express app.
 */
async function main() {
  const userInputs = [
    "I forgot my password and can't log in.",
    "My bill seems higher than expected this month.",
    "What is the capital of France?" // Edge case: General question
  ];

  for (const input of userInputs) {
    console.log(`\n--- Processing: "${input}" ---`);
    try {
      await classifyIntent(input);
    } catch (e) {
      // In a real app, this would trigger a fallback to a human agent
      console.log("Fallback triggered due to classification failure.");
    }
  }
}

// Run the main function
if (require.main === module) {
  main();
}

Visualization: Data Flow

The following diagram illustrates the flow of data from the user input, through the LLM, to the validated TypeScript object.

This diagram illustrates the complete data flow, starting from raw user input, processing it through an LLM to generate a JSON response, and finally validating that response into a strongly-typed TypeScript object.
Hold "Ctrl" to enable pan & zoom

This diagram illustrates the complete data flow, starting from raw user input, processing it through an LLM to generate a JSON response, and finally validating that response into a strongly-typed TypeScript object.

Detailed Line-by-Line Explanation

1. Define the Zod Schema (The Contract)

const IntentSchema = z.object({
  intent: z.enum(["password_reset", "billing_inquiry", "technical_support", "general_question"]),
  confidence: z.number().min(0).max(1),
  requires_human_agent: z.boolean(),
});
  • Why: We cannot trust the LLM to invent a consistent structure. z.enum restricts the intent field to specific values, preventing the model from returning "password_help" (which might break your routing logic).
  • Under the Hood: z.object creates a schema that expects a JSON object. z.number().min(0).max(1) ensures the confidence score is mathematically valid. This schema acts as a "guardrail" for the LLM's generation process.

2. The System Prompt & API Call

const systemPrompt = `
  You are a precise intent classifier...
  Do not output any text before or after the JSON.
`;
// ...
const response = await fetch('http://localhost:11434/api/generate', {
  // ... body: JSON.stringify({ ..., format: 'json', ... })
});
  • Why: The System Prompt is the "instruction manual." By explicitly describing the JSON structure and forbidding extra text, we reduce the likelihood of the model adding conversational filler (e.g., "Sure! Here is the JSON:").
  • Under the Hood: We use the standard fetch API to communicate with Ollama's REST interface. The format: 'json' parameter is a hint to the underlying engine (like llama.cpp) to bias the token sampling towards valid JSON syntax. However, this is not a guarantee, which is why parsing is necessary.

3. Defensive Parsing & Cleanup

const jsonMatch = rawContent.match(/```json([\s\S]*?)```/);
if (jsonMatch) {
  rawContent = jsonMatch[1].trim();
}
const parsedJson = JSON.parse(rawContent);
  • Why: LLMs are trained on Markdown and often wrap code blocks in triple backticks. If we pass "{\n \"intent\": ...\n}" directly to JSON.parse, it works. If we pass "```json\n{\n \"intent\": ...\n}\n```", JSON.parse will throw a syntax error.
  • Under the Hood: The Regex /```json([\s\S]*?)```/ looks for the opening tag, captures everything inside (the [\s\S]*? non-greedy match), and extracts the clean JSON string. .trim() removes surrounding whitespace.

4. Validation with Zod

const validatedIntent = IntentSchema.parse(parsedJson);
  • Why: This is the most critical step for reliability. JSON.parse only checks if the text is valid JSON syntax. It does not check if the JSON contains the correct data types or values (e.g., confidence: "high" instead of 0.9).
  • Under the Hood: IntentSchema.parse() attempts to construct the object. If parsedJson is missing a field or has a wrong type (like a string instead of a number), Zod throws a ZodError. This error object contains detailed information about exactly which field failed validation, which is invaluable for debugging in a production environment.

Common Pitfalls

When implementing structured JSON output with local LLMs in a Web App context, watch out for these specific issues:

  1. The "Async/Await" Loop Trap:

    • Issue: When processing multiple inputs (e.g., batch classification), developers often use forEach with an async callback. forEach does not wait for promises to resolve; it fires them all immediately.
    • Fix: Always use for...of loops or Promise.all if concurrency is safe. In the example above, for (const input of userInputs) ensures sequential processing without blocking the event loop unnecessarily.
  2. Vercel/Serverless Timeouts:

    • Issue: Local LLMs (especially on consumer hardware) can take 2-10 seconds to generate a response. Serverless functions (like Vercel Edge or AWS Lambda) often have strict timeouts (e.g., 10 seconds). If the LLM takes too long, the request times out before the JSON is returned.
    • Fix: Increase the timeout limit in your serverless configuration or move the heavy inference to a dedicated backend server (e.g., a Node.js Express server) that handles long-running tasks without aggressive timeouts.
  3. Hallucinated JSON Keys:

    • Issue: Even with a System Prompt, smaller or less capable models might hallucinate keys. For example, the schema asks for requires_human_agent, but the model outputs needs_human_help.
    • Fix: This is why Zod is non-negotiable. If the model hallucinates a key, IntentSchema.parse() will fail, catching the error before it propagates to your business logic. Always catch ZodError and implement a fallback strategy (e.g., logging the raw output for review).
  4. Token Limit Cutoff:

    • Issue: If the System Prompt is too verbose describing the JSON schema, and the user input is long, the total context window might be exceeded. This often results in the model abruptly cutting off the JSON output mid-stream, causing JSON.parse to fail.
    • Fix: Keep System Prompts concise. Use the format: 'json' parameter to offload the structural burden to the inference engine rather than describing every detail in natural language.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.