On-device LLM on Android (LiteRT-LM)
By default the agent runs its LLM through Ollama on desktop/Editor. On Android,
it can instead run the LLM fully on-device via the LiteRT-LM
runtime, behind the same IAgentTransport interface. The transport is chosen at compile time:
// AgentBuilder.PrepareSetup()
#if UNITY_ANDROID && !UNITY_EDITOR
IAgentTransport transport = new AndroidLlmTransport(dispatcher, "NPU", Log);
#else
IAgentTransport transport = new OllamaTransport(Endpoint, Model, options.AgentName, Log);
#endif
Components
AndroidLlmTransport.cs (C#, IAgentTransport) Runtime/AgentCore/Runtime/
ββ LiteRtModelProvisioner.cs places the .litertlm model in persistentDataPath/LiteRtModels/
ββ AndroidJavaObject β LlmBridge (JNI) Runtime/Plugins/Android/llm-release.aar
ββ LiteRT-LM Engine built from the in-package AndroidBridge~/ source
AndroidLlmTransportmarshals all JNI calls onto the Unity main thread viaIUnityMainThreadDispatcherand wraps the bridge's async callbacks inTasks.- The LiteRT-LM
Engineis large (2β3 GB resident) and is treated as a per-process singleton. - A fresh conversation is created per request; the C# agent layer owns history and the prompt it sends (see Tool calling and context budgeting), avoiding unbounded native KV growth.
Tool calling and context budgeting
The transport uses the bridge's native tool template with a manual, callback-driven loop
(RunNativeToolLoopAsync) β not a prompt-based two-stage protocol. There is no select_tool step and
no schema-withholding:
- Native OpenAPI tools, manual loop.
AndroidLlmTransportbuilds a JSON array of full OpenAPI tool specs β[{name, description, parameters: {schema}}], the parameter schemas are not withheld β and passes it to the bridge's tool-awaregenerate(...)(see JNI contract). The bridge feeds the model's own tool template, the model emits a structured tool call, and the bridge firesonToolCall(callId, name, argsJson). C# runs the tool and replies viaprovideToolResult(callId, result); the whole loop runs inside onebridge.generate()call. LiteRT-LM's fully-automaticautomaticToolCallingpath is deliberately not used β it deadlocks after tool-result injection (callback_thread_pool DEADLINE_EXCEEDED, zero second-pass decode). - Single-engine defer-and-resume (vision / RAG). There is one process-wide LiteRT-LM engine, and it
cannot run a nested
generate()while one is in flight ("busy generating"). So a tool that itself needs the LLM mid-turn cannot make a nested call: instead it defers. A vision tool stashes the captured camera frame; theSearchKnowledgeRAG tool (which needs its own LLM call to translate the query to English, plus any graph-mode keyword extraction) stashes its arguments and answers the bridge with a sentinel so the turn ends.RunNativeToolLoopAsyncthen resumes after the outer turn completes, answering the stashed vision frame with one multimodalgenerate, or running RAG retrieval- answer, once the engine is free. The model still decides when to look or search; only the call is deferred.
- Knowledge queries always search in English. The bundled knowledge base is authored in English, so
SearchKnowledgetranslates a non-English query to English before retrieving (ResolveSearchQueryAsyncinSearchKnowledgeTool, gated byIsLikelyNonEnglishso an already-English query skips the round trip). The translation is its owngenerate()call β on Android it runs at resume time, when the engine is free (the defer-and-resume above is what makes this nested-free), wired viaBuiltinToolDependencies.TranslateToEnglish(set up by the host app). Before the search runs the tool emits a one-line note βπ <μλ¬Έ> β <english>β throughOnToolNote, whichAlexaVoiceViewrenders as a persistent block in the result view so the translation step is visible (the same intermediate-state surface the rest of capture-and-resume uses). If no translator is wired the search falls back to the original query β the multilingual-e5 embedder still matches cross-lingually. - Context budgeting. The Android context budget is 3072 tokens (set by the host app),
deliberately lower than LiteRT-LM's
MAX_NUM_TOKENS = 4096session cap (LlmBridge.kt). The lower budget makes memory compaction (CompactionTriggerPercent = 95) fire before the prompt + tool result can overflow the native window, which is what would otherwise make decoding break down after a few turns.
JNI contract
The AAR must expose exactly this (the C# side depends on the names):
initialize(String modelPath, String backend, InitCallback)
generate(String prompt, byte[] jpeg /* nullable, JPEG bytes */, StreamCallback) // text/multimodal, no tools
generate(String systemPrompt, String prompt, byte[] jpeg, String toolsJson, StreamCallback) // tool-aware overload
provideToolResult(String callId, String result)
interface InitCallback { onReady(); onError(String) }
interface StreamCallback { onChunk(String); onToolCall(String callId, String name, String argsJson); onComplete(String); onError(String) }
generate has two overloads; the 5-arg, toolsJson-carrying one is used when tools are registered.
The StreamCallback has four methods β note onToolCall, which the bridge fires for each tool call;
C# answers via provideToolResult. Image input is JPEG bytes from C#; the bridge decodes and
re-encodes to PNG for LiteRT-LM. Streaming deltas are emitted via onChunk.
Backends
AndroidLlmTransport takes a backend string. Supported values map to LiteRT-LM backends:
| Backend | Notes |
|---|---|
GPU |
OpenCL; silently falls back to CPU where OpenCL is unavailable. Most portable. |
CPU |
Always available, slowest. |
NPU |
Qualcomm Hexagon via QNN. Requires a matching NPU model export and a supported SoC/HTP arch. Falls back to CPU if init fails. |
Choosing a backend. The repository ships with
NPUas the default (set inAgentBuilder), targeting the Galaxy Z Fold 7 (Qualcomm SM8750 / Hexagon v79) NPU with an NPU-exported model β verified working (~25 tok/s, text + multimodal, streaming). NPU support is hardware-specific: it requires an NPU-exported.litertlmmodel and a matching Qualcomm SoC. If your target lacks a supported NPU, switch the backend toGPU(which falls back to CPU) inAgentBuilderand use the plain GPU/CPU.litertlmexport instead of the NPU one.
Model provisioning (development)
For development, side-load the model and let the provisioner copy it into app-private storage on first run:
adb push gemma-4-E2B-it_qualcomm_sm8750.litertlm /data/local/tmp/
LiteRtModelProvisioner copies /data/local/tmp/gemma-4-E2B-it_qualcomm_sm8750.litertlm
(LiteRtModelProvisioner.ModelFileName, the SM8750/Hexagon-v79 NPU export) into
persistentDataPath/LiteRtModels/ on first launch and hands that path to the engine. If the file is
absent it can download from Hugging Face (repo litert-community/gemma-4-E2B-it-litert-lm, revision
main) using a runtime-supplied token (the HfToken field on the provisioner β never commit a
token).
The model file name and backend are set in code today (LiteRtModelProvisioner.ModelFileName,
AgentBuilder); make them configurable for your device/model.
Building the AAR
The prebuilt llm-release.aar ships inside the framework package
(Runtime/Plugins/Android/), so there is no build step to run the sample β just resolve the
transitive Maven deps via Assets βΈ External Dependency Manager βΈ Android Resolver βΈ Resolve.
The Kotlin/JNI source ships inside this package under AndroidBridge~/ (hidden from the importer
by the trailing ~); there is no separate bridge repo. To rebuild the AAR, run ./gradlew assembleRelease (JDK 17) from AndroidBridge~/, then replace Runtime/Plugins/Android/llm-release.aar
with the output. See AndroidBridge~/README.md for the build spec, JNI contract, and third-party
notices.
Player Settings: IL2CPP, ARM64, minSdk 31.
Verifying on device
- First launch: model-copy log, then an "engine ready" log.
- Text prompt β streamed on-device reply (test in airplane mode to prove it is local).
- Image prompt β the model describes the current camera frame (multimodal path).