DEV Community

vmodal_ai
vmodal_ai

Posted on

Build an On-Device LLM Chatbot with Kotlin and TensorFlow Lite

Build an On-Device LLM Chatbot with Kotlin and TensorFlow Lite

Large language models are usually accessed through cloud APIs, but modern Android devices can also run smaller AI models locally. This makes it possible to build applications that work offline and keep sensitive prompts on the device.

In this tutorial, we will design the architecture of an on-device LLM chatbot using Kotlin and TensorFlow Lite. The focus is on the mobile integration layer, model execution, prompt handling, and performance considerations.

What We Will Build

The application will have:

  • A Kotlin Android UI
  • A local TensorFlow Lite model
  • A tokenizer layer
  • Prompt construction
  • Background inference
  • Streaming-style response updates
  • Basic memory and performance management

The exact model and tokenizer implementation depends on the model architecture you choose. Always use a model converted and packaged for the runtime supported by your Android application.

Architecture

A simple architecture looks like this:

Chat UI
   |
ViewModel
   |
LLM Repository
   |
Tokenizer
   |
TensorFlow Lite Interpreter
   |
Local Model
Enter fullscreen mode Exit fullscreen mode

Keeping model execution behind a repository makes it easier to replace the model later.

Project Setup

Create an Android project with Kotlin and add TensorFlow Lite dependencies appropriate for the runtime and model you selected.

For example:

dependencies {
    implementation("org.tensorflow:tensorflow-lite:<version>")
}
Enter fullscreen mode Exit fullscreen mode

Use the current compatible TensorFlow Lite version rather than copying an old version number from a tutorial.

Place your model in:

app/src/main/assets/
Enter fullscreen mode Exit fullscreen mode

For example:

assets/
└── model.tflite
Enter fullscreen mode Exit fullscreen mode

Loading the Model

Create a small model runner responsible for loading the TensorFlow Lite interpreter.

class LlmRunner(
    private val context: Context
) {
    private val interpreter: Interpreter by lazy {
        val model = loadModel("model.tflite")
        Interpreter(model)
    }

    private fun loadModel(name: String): MappedByteBuffer {
        val fileDescriptor = context.assets.openFd(name)

        FileInputStream(fileDescriptor.fileDescriptor).use { input ->
            return input.channel.map(
                FileChannel.MapMode.READ_ONLY,
                fileDescriptor.startOffset,
                fileDescriptor.declaredLength
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The model runner should not be called directly from the main thread.

Tokenization

LLMs operate on tokens rather than normal strings. Your tokenizer must convert the user's prompt into the integer representation expected by the model.

Conceptually:

val prompt = "Explain Kotlin coroutines"
val tokens = tokenizer.encode(prompt)
Enter fullscreen mode Exit fullscreen mode

After inference, the generated token IDs need to be decoded back into text.

val text = tokenizer.decode(outputTokens)
Enter fullscreen mode Exit fullscreen mode

The tokenizer must match the model. Using an incompatible tokenizer can produce invalid input or meaningless output.

Running Inference in the Background

Inference can be computationally expensive, so use a coroutine dispatcher designed for CPU work.

class ChatViewModel(
    private val runner: LlmRunner
) : ViewModel() {

    fun generate(prompt: String) {
        viewModelScope.launch {
            val result = withContext(Dispatchers.Default) {
                runner.generate(prompt)
            }

            // Update UI state here
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This prevents long inference operations from blocking Android's UI thread.

Handling Generated Tokens

A production chatbot should avoid waiting unnecessarily before showing output. Depending on the model runtime, you can expose generated tokens or chunks as they become available.

A simplified API could look like:

interface LlmRunner {
    suspend fun generate(
        prompt: String,
        onToken: (String) -> Unit
    )
}
Enter fullscreen mode Exit fullscreen mode

Then the ViewModel can update the chat state incrementally.

runner.generate(prompt) { token ->
    _response.update { it + token }
}
Enter fullscreen mode Exit fullscreen mode

The actual implementation depends on whether the selected model/runtime supports incremental generation.

Managing Conversation History

Sending the complete conversation to the model on every request increases the token count.

Keep a bounded history:

data class ChatMessage(
    val role: String,
    val content: String
)
Enter fullscreen mode Exit fullscreen mode

Before inference, construct a prompt from only the relevant recent messages.

For example:

val recentMessages = messages.takeLast(10)
Enter fullscreen mode Exit fullscreen mode

For larger applications, consider summarizing older messages instead of keeping everything.

Quantization and Mobile Performance

On-device models can consume significant memory. Quantization can reduce model size and sometimes improve inference performance.

Common approaches include:

  • FP16
  • INT8
  • Weight-only quantization

The best option depends on the model and target device.

You should benchmark:

  • Model loading time
  • First-token latency
  • Tokens per second
  • RAM usage
  • Battery impact
  • Thermal throttling

Avoiding UI Freezes

Never execute model inference inside a click listener directly:

button.setOnClickListener {
    runner.generate(prompt)
}
Enter fullscreen mode Exit fullscreen mode

Instead, move the work into a coroutine or another background execution mechanism.

This is especially important for larger models.

Error Handling

Local inference can fail because of:

  • Insufficient memory
  • Unsupported operators
  • Incorrect tensor shapes
  • Invalid tokenizer configuration
  • Corrupt model files
  • Unsupported device acceleration

Wrap model execution with appropriate error handling and expose useful UI states:

sealed interface ChatState {
    data object Idle : ChatState
    data object Generating : ChatState
    data class Success(val text: String) : ChatState
    data class Error(val message: String) : ChatState
}
Enter fullscreen mode Exit fullscreen mode

Security Considerations

One advantage of local inference is that prompts do not need to leave the device. However, the model itself is part of your application package and may be extracted.

Avoid embedding secrets in the model or APK.

Conclusion

An on-device LLM chatbot combines Kotlin application development with model inference, tokenization, concurrency, and mobile optimization.

The most important production lesson is that AI inference should be treated as a resource-intensive workload. Model size, memory consumption, latency, and thermal behavior all matter on mobile devices.

Once the basic architecture works, you can extend it with conversation memory, retrieval-augmented generation, voice input, function calling, or multimodal models.

Useful Links

SDK Flutter: https://github.com/v-modal/vmodal_sdk_flutter

SDK Android: https://github.com/v-modal/vmodal_sdk_android

Discord: https://discord.gg/K72z28KUx

Top comments (0)