ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

移动端AI Agent实战:从零搭建Android本地Agent原型

移动端AI Agent实战:从零搭建Android本地Agent原型 在移动设备上跑一个 AI Agent和在服务器上跑一个 AI Agent是两套完全不同的设计题。Atlan 是一个以移动端为核心的 AI Agent 原型它的目标不是简单地在手机里封装一个大模型接口而是把手机上的日历、通知、定位、应用间跳转、系统权限和后台生命周期都纳入 Agent 的执行链路。手机既是 Agent 的感知层也是工具执行层还要承担隐私边界控制。真正落地的时候你会发现在服务器上很容易实现的任务规划、工具调用和状态管理到了 Android 系统里会遇到权限回收、进程被杀、上下文超窗、模型返回格式不稳定等一连串问题。这篇文章面向两类读者一类是已经在做 LLM 应用开发的工程师想了解移动端 Agent 和云端 Agent 的架构差异另一类是 Android 开发者想从零搭建一个真正具备工具调用能力的本地 Agent 原型。下面从核心概念开始把 Atlan 拆成可实现的模块再给出最小可运行代码、验证方式和排错思路。1. 先摸清移动端 AI Agent 与云端 Agent 的差异1.1 云端 Agent 的典型运行链路AI Agent 不是简单的“问答增强”它的核心特征是根据用户目标、自主规划、调用外部工具、观察执行结果、修正下一步动作直到完成任务或达到停止条件。云端 Agent 的典型链路很清晰用户输入自然语言目标。后端把目标发给 LLM同时携带一批工具定义。LLM 返回工具调用请求例如search_order、send_email。后端执行工具把结果作为新消息回传给 LLM。LLM 综合结果生成最终回答。这套链路在服务器上很顺畅因为网络稳定、算力充足、权限边界统一代码可以长驻运行进程很少被系统突然回收。大部分开源 Agent 框架默认就是按这个模型设计的。1.2 移动端 Agent 的运行条件完全不同移动端 Agent 的痛点不在“模型能不能推理”而在“能不能稳定拿到本地能力和系统资源”。手机上有通知、短信、日历、定位、相机、应用间跳转、剪贴板、传感器这些既是 Agent 的能力来源也是最容易出错的地方。实际项目里会碰到这些约束资源有限内存、CPU、电量都直接影响推理调用频率。进程不稳定App 切到后台后长时间不操作系统可能直接 kill 进程。网络切换频繁Wi-Fi 和移动网络切换时导致请求失败。权限敏感读取日历、定位、通知栏等都需要动态申请用户随时可以撤销。前台交互优先Agent 不能长期在后台自行执行敏感操作必须让用户知道。1.3 云端方案不能直接照搬用一个表格说明两者的关键差异维度云端 Agent移动端 Agent运行位置服务器或云函数用户设备工具范围企业内部 API、数据库、第三方服务本地应用、系统能力、远程 API上下文来源业务系统、日志、知识库应用内数据、系统状态、用户输入权限模型服务端鉴权统一管控系统运行时权限用户可随时撤销失败模式进程重启、超时重试进程被杀、权限回收、后台限制部署方式后端发版灰度简单应用商店上架版本回滚慢直接把云端 Agent 框架搬进手机最常见的问题是工具执行流程里的中间状态没有持久化进程一被杀整个任务链断裂。移动端 Agent 必须把“任务状态、工具结果、当前消息序列”当作可恢复数据来设计。1.4 移动端 Agent 的价值边界移动端 Agent 最大的价值是能直接操作本机资源而不只是做云端的遥控器。它能读取当前屏幕上的应用状态能触发本地通知能帮你打开某个 App 并且自动跳到指定页面。它需要在“能力”和“权限边界”之间画清楚线。Atlan 的设计前提是Agent 可以做本地操作但所有敏感操作必须先经过用户授权。后面第 5 章会专门讲权限控制这里先明确架构方向。2. 把 Atlan 拆成可落地的模块2.1 Agent 的完整能力地图移动端 Agent 一般由四层组成。感知层负责收集用户输入、系统状态和设备上下文规划层由 LLM 完成目标拆解执行层调用具体的 Skill记忆层保存短期会话和中长期用户偏好。Atlan 的最小版本只需要把它们拆成四个核心组件ChatProxy统一访问 LLM支持端侧模型和云侧模型切换。SkillRegistry注册和管理所有工具每个 Skill 都有名称、描述、参数 JSON Schema、执行函数。AgentExecutor循环执行“LLM 生成 - 工具调用 - 结果回传”的流程。AgentContext保存会话、任务状态、权限状态和日志。组件之间通过接口隔离。比如 SkillRegistry 不关心模型是 Ollama 还是远程服务的它只负责根据工具名称找到对应实现。2.2 Skill 在移动端是什么意思AI Agent 社区里经常出现 tool、function calling、skill 这些术语。在 Atlan 的设计中Skill 是“可被模型调用的具名能力”它比 tool 多了一层执行约束和权限声明。每个 Skill 包含四部分名称模型用来匹配的唯一标识比如open_app。描述说明这个能力在什么场景下触发。参数 JSON Schema定义参数名、类型、默认值、必填项。执行逻辑真正操作 Android 系统或调用远程 API 的函数。举例query_calendar这个 Skill参数可以是startDate和endDate执行时通过 ContentResolver 查询系统日历。再举例一个基于 ES REST API 的日志分析 Skill参数是index、queryDsl和timeRange执行时把请求发送给日志服务返回聚合结果。这样 Agent 就能用自然语言触发一次结构化日志查询。2.3 上下文管理策略移动端 Agent 最容易忽略的是上下文长度。一次简单的工具调用就会产生多轮消息用户输入、模型回复、工具结果、模型再次回复。如果任务链条长消息数量很快超过模型窗口。Atlan 采用两级上下文策略滑窗裁剪 摘要压缩。模型最长窗口假设是 8K token近两轮消息优先保留更早的对话由模型或本地规则生成摘要塞进系统提示词开头。短期会话放在内存任务执行到一半时把上下文持久化到本地数据库这样进程被杀后还能恢复。2.4 端侧模型与云侧模型的混合架构移动端 Agent 不需要只依赖一种模型。Atlan 采用混合架构端侧小模型负责低时延、偏本地、对隐私敏感的初筛例如判断用户输入是否需要敏感权限。云侧大模型负责复杂规划、代码生成、长文本理解。具体调用哪个模型可以在 AgentContext 里配置。学习环境建议先用本地 Ollama 跑通闭环生产环境再接入高可用模型服务。因为本地模型没有密钥、没有网络限制方便排查问题。3. 环境准备与项目骨架3.1 技术选型与前置条件示例采用 Kotlin Android OkHttp kotlinx.serialization。核心逻辑不依赖特定 UI 框架可以复用到 Compose 或传统 View 项目。前置条件Android Studio 最新稳定版。JDK 17。Android SDK 34 或以上。一台 Android 模拟器或真机。本地 Ollama或任意兼容 OpenAI Chat Completions 接口的模型服务。如果使用本地 Ollama模拟器访问宿主机需要通过10.0.2.2真机通过局域网 IP。3.2 项目目录结构项目按功能模块划分而不是所有代码堆在 MainActivity 里app/src/main/java/com/example/atlan/ ├── agent/ │ ├── AgentExecutor.kt │ ├── AgentContext.kt │ ├── ChatProxy.kt │ └── model/ChatModels.kt ├── skill/ │ ├── Skill.kt │ ├── SkillRegistry.kt │ ├── OpenAppSkill.kt │ └── QueryCalendarSkill.kt ├── permission/ │ └── PermissionRequester.kt └── ui/ └── MainActivity.kt目录结构要尽早定好否则 Agent 循环、Skill 注册和权限控制会互相耦合。3.3 依赖配置模块级build.gradle.kts添加依赖dependencies { implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1) implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3) implementation(com.squareup.okhttp3:okhttp:4.12.0) implementation(androidx.appcompat:appcompat:1.7.0) }版本号以实际项目为准建议打开官方版本仓库确认后再写。3.4 AndroidManifest 权限与配置工具调用要声明权限但不建议一开始全部申请。示例先声明网络、日历和通知权限uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.READ_CALENDAR / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS /READ_CALENDAR属于危险权限运行时必须动态申请。POST_NOTIFICATIONS在 Android 13 以上同样要动态申请。INTERNET是普通权限安装时自动授予。注意不要为了省事在 Manifest 里申请所有权限。Android 系统会审计权限使用场景过度声明不仅影响用户信任还可能被应用商店合规检查拒审。4. 实现 Atlan 的最小 Agent 循环4.1 定义消息和工具调用协议先定义最基础的数据结构。要让 LLM 能理解工具调用消息里必须能表达“模型要求调用某个工具”和“工具返回结果”两种角色。Serializable data class ChatMessage( val role: String, val content: String? null, val toolCalls: ListToolCall? null, val toolCallId: String? null ) Serializable data class ToolCall( val id: String, val type: String function, val function: FunctionCall ) Serializable data class FunctionCall( val name: String, val arguments: String ) Serializable data class ToolDefinition( val name: String, val description: String, val parameters: String )ToolDefinition.parameters在实际接入模型时通常是 JSON Schema 字符串这里先用字符串保存发送请求时直接嵌入。4.2 编写 LLM 客户端LLM 客户端要完成三件事拼接请求体、发送 HTTP 请求、解析响应。示例使用 OkHttp接口兼容 OpenAI Chat Completions方便接本地 Ollama 或远程服务。class ChatProxy( private val baseUrl: String, private val modelName: String ) { private val client OkHttpClient() suspend fun chat( messages: ListChatMessage, tools: ListToolDefinition ): ChatMessage withContext(Dispatchers.IO) { val requestBody buildJsonObject { put(model, modelName) put(messages, JsonArray(messages.map { it.toJson() }.toList())) if (tools.isNotEmpty()) { put(tools, JsonArray(tools.map { it.toJson() }.toList())) } put(temperature, 0.2) }.toString() val request Request.Builder() .url($baseUrl/chat/completions) .post(requestBody.toRequestBody(application/json.toMediaType())) .build() val response client.newCall(request).await() if (!response.isSuccessful) { throw IllegalStateException(HTTP ${response.code}: ${response.body?.string()}) } parseChoice(response.body?.string().orEmpty()) } }这段代码有两个关键设计。第一temperature调低到 0.2因为 Agent 工具调用希望输出稳定不需要太多创意。第二请求体把工具列表和消息一起发给模型模型才有机会返回工具调用请求。4.3 工具注册表和内置 Skill定义 Skill 接口所有工具都实现同一个接口interface Skill { val name: String val description: String val parameters: String val requiresPermission: String? suspend fun execute(arguments: JsonObject, context: AgentContext): String }requiresPermission是 Atlan 权限控制的关键字段。普通读取工具可以为空危险操作必须返回对应权限名称。SkillRegistry 用 Map 管理所有 Skillclass SkillRegistry( private val skills: ListSkill ) { private val map skills.associateBy { it.name } fun definitions(): ListToolDefinition skills.map { ToolDefinition( name it.name, description it.description, parameters it.parameters ) } fun find(name: String): Skill? map[name] }一个最简的open_appSkill 实现class OpenAppSkill( private val packageManager: PackageManager ) : Skill { override val name open_app override val description 打开手机上指定的应用参数为包名。 override val parameters { type: object, properties: { packageName: {type: string, description: 应用包名} }, required: [packageName] } .trimIndent() override val requiresPermission: String? null override suspend fun execute(arguments: JsonObject, context: AgentContext): String { val packageName arguments[packageName]?.jsonPrimitive?.content ?: return 缺少 packageName 参数 val intent context.packageManager.getLaunchIntentForPackage(packageName) ?: return 未找到应用 $packageName context.startActivity(intent) return 已尝试打开 $packageName } }这里要解释工具执行结果不一定是“成功”可能返回“缺参数”“未找到应用”。Agent 会把这段文字回传模型模型再决定下一步。4.4 AgentExecutor 执行循环执行循环是 Agent 的心脏。它不断把消息列表发给模型如果模型返回工具调用就执行工具并把结果作为tool角色消息写回如果模型返回普通文本说明任务完成。class AgentExecutor( private val chatProxy: ChatProxy, private val skillRegistry: SkillRegistry ) { suspend fun run(userInput: String, context: AgentContext): String { val messages mutableListOf( ChatMessage(role system, content context.systemPrompt()), ChatMessage(role user, content userInput) ) repeat(MAX_ITERATIONS) { round - val response chatProxy.chat(messages, skillRegistry.definitions()) if (response.toolCalls.isNullOrEmpty()) { return response.content.orEmpty() } messages response context.log(round$round plan${response.content}) for (toolCall in response.toolCalls) { val skill skillRegistry.find(toolCall.function.name) if (skill null) { messages ChatMessage( role tool, toolCallId toolCall.id, content 未找到工具 ) continue } if (!context.hasPermission(skill.requiresPermission)) { val granted context.requestPermission(skill.requiresPermission) if (!granted) { messages ChatMessage( role tool, toolCallId toolCall.id, content 用户拒绝了权限任务中止 ) continue } } val arguments Json.parseToJsonElement(toolCall.function.arguments).jsonObject val result skill.execute(arguments, context) context.log(tool_call${skill.name} result$result) messages ChatMessage( role tool, toolCallId toolCall.id, content result ) } } return 超过最大执行轮次任务未完成 } companion object { const val MAX_ITERATIONS 6 } }循环里有三个必须控制的地方。第一MAX_ITERATIONS防止模型在工具调用里死循环。第二权限被拒绝时不能假装成功要把“用户拒绝”作为工具结果回传模型才能改口。第三每一轮的工具调用顺序必须保留否则模型无法把结果和调用对应上。5. 生命周期、权限与安全设计5.1 工具调用前的权限校验移动端 Agent 的危险不是模型本身而是模型可能生成一个高权限工具调用。比如读取用户通讯录、发送短信、删除日历事件。如果模型输出send_sms应用直接执行后果很严重。Atlan 的规则是AgentContext 中维护一个权限矩阵每个 Skill 对应的权限由开发者在requiresPermission字段中声明。执行前检查未授权时挂起并弹出系统权限请求或应用内确认对话框。suspend fun requestPermission(permission: String?): Boolean { if (permission null) return true return withContext(Dispatchers.Main) { // 跳转到系统权限页或应用内确认对话框 } }生产环境需要把“请求了哪个权限、用户是否同意、执行了哪个工具”写入审计日志。合规和排查都离不开这条记录。5.2 敏感操作的二次确认危险权限分成两类。一类是系统危险权限比如定位、日历另一类是应用自己定义的敏感操作比如“发送短信”“删除照片”。对于后者即使系统已授权也建议在 UI 上做二次确认。推荐做法AgentContext 中允许标记requiresUserConfirmation true的 Skill。执行时不是直接调用而是把待执行动作推送到界面让用户看到“模型准备打开 XX 应用是否允许”用户点击允许后才继续。这个设计牺牲了一点流畅性但换来了可解释性和安全性。5.3 前后台与进程约束Agent 循环在协程里运行一旦进入后台进程可能被系统回收。学习环境可以不做特殊处理生产环境必须考虑两条路短任务用前台服务持有进程配合通知栏常驻提示。长任务把任务状态持久化到 RoomAgent 重新启动后从上次未完成的位置继续。设计时尽可能把任务拆成可重入的步骤避免“执行一半进程被杀整个任务链作废”。5.4 日志与可观测性移动端 Agent 的调试难度比普通 App 高因为多了一次模型决策。建议统一使用固定 TAG并记录四类日志[agent-plan]模型给出的计划或中间文本。[agent-tool-call]模型选择调用哪个工具、参数是什么。[agent-tool-result]工具返回结果或异常。[agent-reply]最终回复。日志里尤其要记录toolCallId。排查多轮工具调用时没有它就很难对齐哪条结果对应哪个调用。6. 运行验证与结果分析6.1 最小验证场景产品环境先把一个最简单的本地场景跑通用户输入“打开系统设置”Agent 调用open_app打开设置应用并返回文本。使用 Ollama 时先拉取模型并启动服务ollama pull qwen2.5:7b ollama serveAndroid 模拟器访问宿主机地址配置为val chatProxy ChatProxy( baseUrl http://10.0.2.2:11434/v1, modelName qwen2.5:7b )运行 App 后输入目标文字等待 Agent 循环返回。预期日志[agent-plan] 用户想要打开系统设置我调用 open_app 工具。 [agent-tool-call] nameopen_app args{packageName:com.android.settings} [agent-tool-result] 已尝试打开 com.android.settings [agent-reply] 我已经打开了系统设置页面。6.2 单元测试示例Agent 循环适合写单元测试重点不是测 LLM而是测工具参数解析和循环逻辑。可以用一个 fake ChatProxy 模拟模型返回工具调用再验证 Skill 是否被执行。class FakeChatProxy : ChatProxyStub() { override suspend fun chat(...): ChatMessage { return ChatMessage( role assistant, toolCalls listOf( ToolCall( id call_1, function FunctionCall( name open_app, arguments {packageName:com.android.settings} ) ) ) ) } } Test fun open_app skill should be invoked() runBlocking { val executor AgentExecutor(fakeProxy, registry) val result executor.run(打开设置, context) assertTrue(context.startedActivity) }测试通过的标准是fake 模型返回调用后工具被找到、参数被解析、执行函数被触发、工具结果被回传。6.3 结果判断与模型参数建议Agent 循环跑通后还要判断“这套提示词和工具描述是否稳定”。可以从三个角度检查是否每次都返回合法 JSON 参数。是否在上下文足够时选择了正确工具。是否在工具结果不符合预期时进行了二次规划。如果模型频繁返回错误工具优先检查工具描述。描述越具体模型越容易匹配。例如不要写“处理应用”要写“打开手机上某个应用参数是目标应用的包名”。如果参数解析经常失败在 JSON Schema 的 description 里给示例值。7. 常见问题排查7.1 模型返回工具参数无法解析现象日志中toolCall.function.arguments不是合法 JSON或缺少必填字段。排查顺序先打印原始 arguments。检查 JSON Schema 是否声明了 required。检查工具描述是否给过示例。解决方案在解析失败时不要直接崩溃返回一段错误文本给模型让模型重新生成参数。在 JSON Schema 的properties里补充示例值最有效。7.2 上下文超出模型窗口现象请求 400提示 context length exceeded。排查顺序打印 messages 数量和大致 token 数。检查工具结果是否太长例如日志查询返回了上百条明细。在工具层限制返回条数比如最多返回 20 条。解决方案工具结果尽量返回摘要而不是完整数据。例如 ES 日志分析 Skill 只返回聚合结果和错误样本明细放到分页接口。7.3 用户撤销权限后工具调用仍失败现象用户曾经授权但运行一段时间后权限被系统回收Agent 再次调用时失败。排查顺序检查系统设置里的权限状态。查看代码是否每次执行前都做了权限检查。查看requiresPermission是否和实际工具声明一致。解决方案每次执行前检查权限而不是只在启动时申请。权限被拒绝后把拒绝结果写入消息模型才能友好提示用户。7.4 模型返回格式不稳定现象同一任务有时能调用工具有时直接给一段文字不调用工具。排查顺序检查temperature是否过高。检查 messages 中历史工具调用是否污染了意图。检查模型本身是否支持 function calling。解决方案先把 temperature 降到 0再调整系统提示词。如果模型对 tool call 支持不稳定可以在系统提示词中强制要求“必须调用工具后再解释”。7.5 后台任务被系统杀死现象Agent 执行到一半App 切后台再回来发现任务中断。排查顺序查看 logcat 是否有Process ... has died。检查任务状态是否持久化。检查是否有前台服务。解决方案长任务必须有持久化状态推荐 Room WorkManager。Agent 重新启动时先从库里恢复未完成会话再继续执行。7.6 排查链路总览问题现象可能原因检查方式处理建议工具参数解析失败描述语义不清晰、缺少示例打印 arguments 原文完善 JSON Schema失败时回传错误让模型重试上下文超窗工具结果过长、消息未裁剪统计 messages 的 token 数工具返回摘要滑窗裁剪历史系统权限被回收运行时权限模型变化查询权限状态执行前每次都检查并动态申请模型不调用工具temperature 过高、模型不支持对比不同模型输出降低 temperature更换支持 function calling 的模型后台进程被杀未持有前台服务、状态未持久化查看 logcat 进程死亡记录长任务用前台服务 Room 持久化状态日志查询结果过多查询未限制条数查看 Skill 返回内容返回聚合结果明细分页8. 学习环境与生产环境的差距8.1 学习环境可以简化哪些环节本地跑通 Atlan 原型尽量简化直接用本地 Ollama省去密钥和网络代理配置。权限交互可以先用硬编码的“同意”专注 Agent 循环本身。任务状态放在内存不要求进程被杀后恢复。UI 只保留一个输入框和日志展示。学习环境的目标是让你看清 Agent 循环的原理因此不要一开始就上复杂架构。8.2 生产环境必须补齐的能力生产环境不是把代码搬上去就行至少还要补齐这些能力能力学习环境生产环境模型配置写死在 App 里配置外置支持远程下发模型服务本地 Ollama高可用服务带鉴权、限流、监控权限记录不记录记录谁在什么时间授权了什么工具任务状态内存中Room 持久化支持断点恢复日志Logcat上传到日志平台带上 traceId异常处理直接返回错误异常上报 用户可理解文案工具执行不校验幂等、超时、审计、二次确认后台运行不保证前台服务或任务拆解生产环境中模型返回的内容必须经过脱敏和内容审核工具调用必须遵循最小权限原则所有敏感操作都要让用户显式确认。这一条不能省略。注意生产环境的 Agent 能力边界要在产品层定清楚。不要做“自动发送短信”“自动转账”这类高风险动作至少在 MVP 阶段保留用户确认入口。9. 最佳实践清单与扩展方向9.1 上线前可复用检查清单发布前按以下清单逐项检查能避免大部分问题每个 Skill 的 description 是否说明触发条件、参数含义、示例格式。工具参数是否都有 JSON Schema必填项是否声明。工具执行是否设置超时和最大循环轮次。权限是否都在执行前检查而不是只在启动时检查。敏感操作是否有用户二次确认。上下文是否有裁剪或摘要机制。工具结果是否限制长度。日志是否包含plan、tool_call、tool_result、reply四类关键节点。任务状态是否持久化。模型、API 地址、密钥是否外置配置。是否记录了授权审计日志。9.2 从最小原型走向完整 Agent原型跑通后扩展方向有几个值得关注。多 Agent 协同。移动端 App 不一定只用一个 Agent。可以拆成调度 Agent 和工具 Agent调度 Agent 负责理解用户意图工具 Agent 负责执行具体 Skill。多个 Agent 共享同一个 SkillRegistry但各自维护独立上下文降低单一上下文的复杂度。基于 ES REST API 的日志分析 Skill 是一个很实用的扩展。把规则校验、索引选择、聚合查询封装成 Skill用户就能用自然语言让 Agent 自主分析日志。核心是把查询结果裁剪成模型能消费的摘要避免返回海量明细。能力评估不能只看有没有调用工具成功。移动端 Agent 的性能要从工具选择准确率、参数解析成功率、任务完成率和端到端耗时四个维度衡量。市面上有 Agent benchmark但绝大多数是针对桌面或云端场景移动端 Agent 需要自己构造测试集覆盖系统权限、后台生命周期、离线网络等真实场景。对新人来说最值得练习的方向是不断给 Agent 增加新的 Skill然后在每个 Skill 上补权限检查、参数校验、错误回传和日志。这套练习做完你会比单纯看框架文档更理解 Agent 的工程化难点。
返回列表