LLM.swift

repository·main·Indexed 21 days ago

https://github.com/eastriverlee/llm.swift

A lightweight Swift library for running large language models locally on Apple platforms (macOS, iOS, watchOS, tvOS, visionOS) leveraging llama.cpp. It features support for GGUF models, HuggingFace integration, automatic chat templates via Jinja, and structured JSON output using the @Generatable macro. The library includes capabilities for function calling through the Tool protocol, Chain of Thought reasoning via ThinkingMode, and text embeddings for semantic similarity and search.

Tokens
3K
Snippets
9
Records
12
Agent score
25%

What's inside LLM.swift

  1. Overview of LLM.swift

    main

    LLM.swift is a Swift library that provides a native interface for running Large Language Models (LLMs) locally on Apple platforms (iOS, macOS, tvOS, and visionOS). It wraps llama.cpp to leverage hardware acceleration via Metal.

    Key capabilities include:

    • SwiftUI Integration: Uses ObservableObject for easy state management in SwiftUI.
    • Automatic Chat Templates: Uses the Jinja engine embedded in GGUF files to format conversations correctly.
    • Function Calling: Supports native tool-calling formats using the Tool protocol.
    • Structured Output: Uses @Generatable to enforce grammar-constrained JSON generation.
    • Streaming: Provides real-time token generation via AsyncStream, including support for separating 'thinking' (reasoning) output.
  2. Access Chain of Thought via ThinkingMode

    main

    For models that support Chain of Thought (CoT), LLM.swift allows you to separate the model's internal reasoning from its final response.

    Using ThinkingMode

    When calling respond, you can specify a thinking mode:

    • .enabled: Appends the thinking start token to force the model to think. The library parses and separates the content.
    • .suppressed: Uses the "nothink" technique (appending empty thinking tokens) to force the model to skip thinking.
    • .none: (Default) No special handling for thinking tokens.

    Retrieving Content

    • bot.thinking: Contains the accumulated reasoning/thought process.
    • bot.output: Contains the final response (excluding the thinking process).

    Example:

    // Enable thinking
    await bot.respond(to: input, thinking: .enabled)
    
    // Access results
    print("Thought: \(bot.thinking)")
    print("Response: \(bot.output)")
    await bot.respond(to: input, thinking: .enabled)
    print(bot.thinking)
  3. Core Components of LLM.swift

    main

    The library is organized into several functional modules:

    Model Management

    • HuggingFaceModel: Used for downloading and managing models directly from Hugging Face.

    Chat System

    • Chat: Manages conversation state.
    • Role: Defines participant roles (e.g., user, assistant).
    • Template: Allows for manual overrides of the model's embedded chat template.
    • ThinkingMode: Handles the separation of reasoning/thinking tokens from final responses.

    Function Calling & Structured Output

    • Tool & ToolCall: Protocols and types for defining and executing model tools.
    • StructuredOutput: Mechanisms for generating JSON based on @Generatable schemas.

    Utilities

    • Token, Batch, Model, and Vocab: Low-level primitives for handling model data and processing.
  4. Customize LLM behavior with preprocess, postprocess, and update

    main

    When using the LLM class, you can hook into the response lifecycle by defining four specific properties. These are used during the execution of the respond function:

    • preprocess: A closure used to format user input and conversation history into a string that conforms to a specific chat template (e.g., ChatML). It receives the input, history, and current thinking mode.
    • postprocess: A closure executed after the model finishes generating an output. The default is { print($0) }. This is useful for triggering logic like function calling based on the final text.
    • update: A closure called every time a new outputDelta (a chunk of text) is received during streaming. outputDelta is nil when generation stops.
    • updateThinking: A closure called when new thinking tokens (Chain of Thought) are received.

    If you require fine-grained control over the streaming process, you can override the open func respond(to input: String) async method in a subclass.

  5. How chat templates work in LLM.swift

    main

    By default, LLM.swift uses the chat template embedded within the .gguf file (via llama.cpp's Jinja engine). This allows models to work out-of-the-box without manual template configuration.

    Key behaviors:

    • Automatic Templates: If you don't pass a template: parameter, the library uses the model's embedded template.
    • Manual Overrides: You can pass an explicit Template (e.g., .gemma, .chatML) to override the embedded one if the metadata is missing or broken.
    • Incremental Context: Conversation context is maintained incrementally between turns; only new tokens are evaluated, and history is not re-fed to the model every turn.
    • Thinking/Reasoning: For models that support it, reasoning/thinking separation is handled automatically via bot.thinking without extra configuration.
    // Using embedded template automatically
    let bot = try await LLM(from: HuggingFaceModel("unsloth/Qwen3-0.6B-GGUF", .Q4_K_M))!
    bot.systemPrompt = "You are a sentient AI with emotions."
    await bot.respond(to: "What's the meaning of life?")
  6. Configure Chat Templates using the Template struct

    main

    To ensure user input conforms to a model's expected chat format (like ChatML), use the Template struct. Instead of manually writing a preprocess function, you can set the template property on your LLM instance. This automatically configures both preprocess and stopSequence.

    Common ways to set a template:

    1. Using built-in static methods: self.template = Template.chatML("System Prompt").
    2. Manual configuration: Define custom start/end tokens for system, user, and bot roles.

    Example of manual Template configuration:

    self.template = Template(
        system: ("<|im_start|>system\n", "<|im_end|>\n"),
        user: ("<|im_start|>user\n", "<|im_end|>\n"),
        bot: ("<|im_start|>assistant\n", "<|im_end|>\n"),
        stopSequence: "<|im_end|>",
        systemPrompt: "You are a sentient AI with emotions."
    )
    self.template = Template.chatML("You are a sentient AI with emotions.")
  7. Implement Function Calling (Tools)

    main

    You can extend the model's capabilities by providing it with Tools. A tool allows the model to call your Swift code to retrieve information or perform actions.

    Implementation Steps:

    1. Create a struct that conforms to the Tool protocol.
    2. Define a nested struct for the arguments, annotated with @Generatable (this generates the JSON schema the model uses).
    3. Implement the call(_:) method to execute your logic.
    4. Assign the tool(s) to the bot.tools array.

    Important Notes:

    • Template Requirement: Tool use requires the model's native tool-call format. You must use the embedded template (do not pass a manual template: during initialization) for this to work.
    • Monitoring: Tool calls are recorded in the @Published var toolCalls: [ToolCall] property.
    • Error Recovery: If a tool fails or arguments fail to decode, the error is fed back to the model to allow it to attempt recovery.
    struct GetWeather: Tool {
        let description = "Get the current weather for a city"
    
        @Generatable
        struct Arguments {
            let city: String
        }
    
        func call(_ arguments: Arguments) async throws -> String {
            "It is sunny and 22 degrees celsius in \(arguments.city)."
        }
    }
    
    let bot = try await LLM(from: HuggingFaceModel("unsloth/Qwen3-0.6B-GGUF", .Q4_K_M))!
    bot.systemPrompt = "You are a helpful assistant."
    bot.tools = [GetWeather()]
    await bot.respond(to: "What is the weather in Seoul right now?")
  8. Initialize LLM with a HuggingFace model

    main

    You can download and initialize a model directly from HuggingFace using HuggingFaceModel. This method is asynchronous and can provide progress updates during the download/loading process.

    let systemPrompt = "You are a sentient AI with emotions."
    let bot = await LLM(from: HuggingFaceModel("unsloth/Qwen3-0.6B-GGUF", .Q4_K_M, template: .chatML(systemPrompt)))!
    let question = bot.preprocess("What's the meaning of life?", [])
    let answer = await bot.getCompletion(from: question)
    print(answer)
  9. Generate structured output with @Generatable

    main

    Use the @Generatable macro to ensure the model produces type-safe, valid JSON that conforms to a specific Swift structure. This is highly reliable for programmatic workflows.

    To use it:

    1. Annotate your struct or enum with @Generatable.
    2. Use the respond(to:as:) method, passing the type as the second argument.
    3. Access the result via the .value property of the returned object.
    @Generatable
    struct Person {
        let name: String
        let age: Int
        let occupation: String
        let personality: String
    }
    
    let bot = LLM(from: Bundle.main.url(forResource: "model", withExtension: "gguf")!, template: .chatML("You are helpful."))
    let result = try await bot.respond(to: "Create a fictional character", as: Person.self)
    let person = result.value // Guaranteed to be a valid Person struct
    print(person.name)
  10. Generate and compare text embeddings

    main

    LLM.swift provides an Embeddings struct to support semantic similarity and search tasks.

    Key Operations

    • Generation: Use try await bot.getEmbeddings("text") to create an embedding.
    • Comparison: Use embeddings1.compare(with: embeddings2) to get a similarity score between 0.0 and 1.0.
    • Search: Use embeddings1.findMostSimilar(in: [candidate1, candidate2]) to find the closest match in a set.

    Example:

    let embeddings1 = try await bot.getEmbeddings("Hello world")
    let embeddings2 = try await bot.getEmbeddings("Hi there")
    
    // Compare similarity
    let similarity = embeddings1.compare(with: embeddings2)
    print(similarity) // e.g., 0.8
    
    // Find most similar
    let mostSimilar = embeddings1.findMostSimilar(in: embeddings2, embeddings3)
    let embeddings1 = try await bot.getEmbeddings("Hello world")
    let embeddings2 = try await bot.getEmbeddings("Hi there")
    let similarity = embeddings1.compare(with: embeddings2)
    print(similarity)
  11. Initialize LLM with a bundled model file

    main

    If you have a .gguf model file bundled in your app's resources, you can initialize the LLM class by providing the file URL and an optional Template.

    let bot = LLM(from: Bundle.main.url(forResource: "gemma-3-4b-it-q4_0", withExtension: "gguf")!, template: .gemma)
    let question = bot.preprocess("What's the meaning of life?", [])
    let answer = await bot.getCompletion(from: question)
    print(answer)