Android AI应用:用Function Calling让大模型真正执行系统操作

Android AI应用:用Function Calling让大模型真正执行系统操作 如果你也在做 Android 里的 AI 应用大概率会遇到同一个尴尬模型聊天很溜真让它调个系统设置、查个天气、建个日程它就只会一本正经地教你手工操作。Function Calling也叫工具调用就是解决这个问题的关键机制模型不需要真的去按下手机上的按钮它只负责输出“我想调用某某函数参数是这样的”真正点击执行的人是我们自己的 Android 代码。我最近在做一个纯本地的生活助手目标是让用户用一句话完成「开启勿扰」「创建日程」「问问北京明天要不要带伞」这类操作。折腾了三周从顶层协议到权限模型都踩了一遍。这篇把完整的工程思路、请求回路和排错过程写出来给正在做 Android AI 的项目留一份参考。无论你接入的是云端模型还是本地小模型核心链路是一致的。1. 先搞清楚模型会「说」不等于会「做」Function Calling 补的是哪一环1.1 大模型只是「文字生成器」不是操作系统需要先建立一个前提认知大模型再聪明本质也只是文字生成器。你给它一句话它预测最合理的下一段文本它可以写周报、写诗、讲故事但它不是 Android 系统组件碰不到 Context、PackageManager更不会直接调用 NotificationManager。很多人做 AI 应用卡住就是因为把「模型」当成了「万能执行器」直接让大模型去开关蓝牙、建日程、发通知。模型确实愿意配合可它的输出基本都是“我来帮你打开勿扰模式”这样的文字听起来很真诚实际上什么也没发生。举一个最典型的例子。你把下面这句发给模型“帮我把手机调成勿扰模式下午三点有个会。”模型会生成什么它大概率会生成一段完整的操作步骤甚至贴心地提醒你“在设置-声音与振动里找到勿扰模式”。这当然有价值但对一个智能助手来说完全不合格。用户要的是一个动作不是一篇教程。这就是很多 Android AI 项目一开始就搞反的地方以为把用户的自然语言直接丢给模型再把模型答案展示出来就叫 AI 助手。实际上你只做了一个“更聪明的搜索框”离“干活”还差着十万八千里。1.2 Function Calling 的本质模型输出的是一个「调用意图 JSON」那真实项目里怎么处理去看 OpenAI、Anthropic、Google 等平台的 API 文档你会发现它们描述的 Function Calling 机制有一个很容易被误读的地方它叫“函数调用”但它并没有真的执行你的函数。模型只是在生成文本的过程中多了一条可选的输出路径输出一个结构化 JSON 块例如{ name: open_do_not_disturb, arguments: {\enable\: true} }name 字段告诉客户端“我想调用那个工具”arguments 是一个字符串形式的参数 JSON。真正负责解析、授权、执行、返回结果的是客户端的代码。模型本身只是输出一个“意图”也就是一张写好的采购单。这个边界非常关键。它意味着两件事第一你可以完全控制“什么时候真正执行”。模型建议调用不代表它有权调用。你可以做权限校验、二次确认、后台任务调度甚至直接拒绝执行。第二你必须自己实现一个完整的执行回路。模型把意图 JSON 抛给你之后你要去执行再把执行结果以消息形式送回给模型让模型基于真实结果组织自然语言回答。打个比方Function Calling 不是让管家亲自下厨而是管家写好一张采购单你负责拿着单子去采购。真正导致项目翻车的往往不是模型不会写采购单而是没人把单子接过去认真执行。2. 路线选型本地小模型、云端大模型还是混合调用2.1 本地模型跑 Function Calling 的现状既然做了 Android 端你肯定纠结过一个问题模型能不能直接跑在手机上毕竟隐私、离线、成本都是现实需求。先说结论本地模型可以跑 Function Calling但工程复杂度比云端高一个量级。目前确实有部分开源模型支持工具调用比如 Qwen 系列里的较新版本在模型卡里会明确标出支持 function calling。你要做的是把模型量化后塞进 Android再通过 llama.cpp、MediaPipe LLM Inference 这类运行时加载。真正跑起来你会发现几个痛点参数量太小的模型指令遵循能力不稳定经常把工具说明当成普通文本复述而不是生成规范的调用 JSON。各家的工具调用格式不一定兼容 OpenAI 的 tools 协议有的用自己的 function call 格式需要你做专门的 prompt 模板适配。手机端侧的上下文窗口有限而 tools 描述本身会占用大量 token。工具一多光是描述就能把一个 3B 模型的上下文塞爆。所以我不建议第一个版本就直接上本地模型。更适合的路径是先用稳定的云端模型把“模型到系统动作”的链路彻底打通跑通完整体验后再挑一两个场景迁移到端侧模型。到时候你已经有了稳定的执行器抽象替换底层推理引擎只是替换一个适配层的事。2.2 在 Android 端落地时我推荐的折中方案对于一个要快速上线或做 Demo 验证的 Android 项目我的建议很直接使用一个兼容 OpenAI 格式的模型 API无论你接的是官方服务还是其他兼容网关统一走/v1/chat/completions这套协议。Android 工程内只依赖一个统一的客户端接口不关心上游是 GPT、Gemini 还是国内开源模型。后续换厂商只改 baseUrl 或者 API Key。客户端保留 ToolRegistry把“给模型看的工具描述”和“真正执行的代码”分开。等产品验证完毕再根据隐私需求决定是否需要端侧推理。为什么推荐统一兼容 OpenAI 格式因为生态最成熟。许多本地模型部署框架也提供 OpenAI 兼容入口这意味着你可以在不改变协议的情况下随时把请求从远端切到本机。客户端代码几乎不用动。提示技术选型最忌讳一开始追求“本地优先”。先把产品逻辑跑通比讨论部署形态重要得多。3. Android 工程骨架把「Function Calling」变成可维护的代码3.1 项目需要的核心模块一个可以在真实项目里持续迭代的 Function Calling 链路不能只是“拼一个 HTTP post”。拆开看它至少需要四个模块模块职责关键内容网络层负责与模型服务通信OkHttp 客户端、超时策略、日志拦截器协议层定义请求/响应模型ChatMessage、ToolSpec、ToolCall、ChatResponse执行器真正调用 Android 能力ToolExecutor、ToolRegistry、权限检测会话循环把用户意图变成完整的多轮调用第一轮请求、执行工具、第二轮回填、循环退出这四个模块一开始就分开后面加一个新技能会特别顺畅。如果全塞在一个 Activity 或者 ViewModel 里初期会跑得很爽等工具数量突破个位数维护成本会直线飙升。3.2 最小依赖配置网络权限与超时设置使用 OkHttp Gson Coroutines 就够了不必为第一版引入太重的东西。在build.gradle.kts里加上dependencies { implementation(com.squareup.okhttp3:okhttp:4.12.0) implementation(com.google.code.gson:gson:2.10.1) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3) }然后是 AndroidManifest 里的网络权限uses-permission android:nameandroid.permission.INTERNET /如果你在本地测试连的是 HTTP 明文地址还需要在application标签上打开明文流量application android:usesCleartextTraffictrue ... 本地调试可以这样生产环境建议用 HTTPS 并通过 Network Security Config 限制域名而不是一刀切放开明文。有一个特别容易踩的配置点是超时。大模型生成速度没那么快如果你沿用普通接口的 10 秒超时几乎必然失败。我第一版就设了 30 秒结果一遇到长回答就断。后来改成下面这组超时配置才稳定val client OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .build()第一版不要着急做流式输出。先把非流式请求跑通能大幅降低调试复杂度。流式 SSE 涉及事件解析、取消、缓存等一堆问题等主流程稳定后再升级不迟。3.3 用 ToolRegistry 管理每个工具的两张脸我把每个可以调用的能力抽象成ToolExecutorinterface ToolExecutor { // 给模型看的声明包括函数名、描述、参数 JSON Schema fun toolSpec(): ToolSpec // 真正执行入参是模型返回的参数 JSON出参是回传给模型的字符串 fun execute(args: JsonObject): String }为什么必须把“声明”和“执行”拆开因为给模型看的描述不是从代码自动生成的。描述怎么写直接决定模型什么时候调用、参数填得对不对。接下来用一个注册表管理所有工具避免在每个调用的地方写when(name)class ToolRegistry { private val executorMap mutableMapOfString, ToolExecutor() fun register(executor: ToolExecutor) { executorMap[executor.toolSpec().name] executor } fun specs(): ListToolSpec executorMap.values.map { it.toolSpec() } fun execute(name: String, args: JsonObject): String { val executor executorMap[name] ?: return {error: tool $name not found} return try { executor.execute(args) } catch (t: Throwable) { {error: ${t.message}} } } }这里 try/catch 很关键。如果执行过程中出现权限缺失、Intent 找不到应用、JSON 解析失败任何异常都不能让整个会话循环崩溃。正确姿势是捕获异常转成一段错误文本返回给模型。模型看到这段错误才有可能生成“抱歉我没有权限打开勿扰模式”这样的真实回答而不是假装成功。注意ToolRegistry 里返回的错误字符串必须稳定且简单。模型不是程序员不要给它一堆 StackTrace它会晕。给一个错误码加一句人话提示就够了。4. 唯一绕不过去的核心怎么把系统能力「翻译」给模型看4.1 tools 参数的潜规则如果功能不调用大概率不是网络问题而是 tools 描述写得不到位。这里的“描述”不是你随便写一句话就行而是模型在生成过程中唯一能看到的“说明书”。它必须同时回答三个问题这个功能是干嘛的用户怎么说的时候应该调用它参数分别是什么意思、有什么限制先看最标准的工具描述结构[ { type: function, function: { name: query_weather, description: 查询某个城市未来几天的天气。当用户询问天气、降雨概率、气温、是否适合出门时使用。, parameters: { type: object, properties: { city: { type: string, description: 城市名使用中文例如北京、上海 }, days: { type: integer, description: 查询未来几天的天气默认1最大5 } }, required: [city] } } } ]这段 JSON 里description的价值远大于name。模型靠它判断触发时机参数里的description决定模型能不能填对值。缺了“当用户说...时调用”这种触发话术模型经常会选择困难甚至把工具当摆设。一个实用的经验是把“用户怎么说才调用”直接揉进描述里。比如坏描述设置勿扰模式好描述当用户说开启勿扰、会议中不想被打扰、请勿打扰时调用。如果用户没提具体模式默认开启 priority 模式再比如城市参数不要只写“城市”要写明“城市名使用中文”。否则模型很可能把用户口中的“北京”自动翻译成“Beijing”甚至返回拼音下游的天气接口直接报错。这些细节才是 Function Calling 真实工程里最花时间的地方。4.2 一个示例把「查天气 / 开勿扰 / 建日程」翻译成 Schema以我做的生活助手为例假设第一版只需要三个能力查天气、开启勿扰、创建日历日程。那它们对应的描述可以做这样的设计函数名描述要点参数query_weather查天气、降雨、气温、是否适合出门用户没说城市时填定位城市city: string; days: integerset_do_not_disturb用户说开启勿扰、勿扰模式、会议中不想被打扰无参数时默认开启enable: booleancreate_calendar_event用户在“帮我记一下”“明天下午三点开会”时调用title: string; startTime: string; endTime?: string其中 set_do_not_disturb 的实现里我会调用系统的 NotificationManagerclass DoNotDisturbTool(private val context: Context) : ToolExecutor { override fun toolSpec(): ToolSpec ToolSpec( name set_do_not_disturb, description 当用户说开启勿扰、开启免打扰、会议中不想被打扰时调用。用户没提具体模式时默认开启。, parameters ToolParameters( type object, properties listOf( ToolProperty( name enable, type boolean, description true 表示开启勿扰false 表示关闭勿扰 ) ), required listOf(enable) ) ) override fun execute(args: JsonObject): String { val enable args.get(enable).asBoolean val nm context.getSystemService(NotificationManager::class.java) if (!nm.isNotificationPolicyAccessGranted) { return {ok: false, error: NOTIFICATION_POLICY_ACCESS_DENIED, hint: 需要引导用户到系统设置开启勿扰权限} } nm.setInterruptionFilter( if (enable) NotificationManager.INTERRUPTION_FILTER_PRIORITY else NotificationManager.INTERRUPTION_FILTER_ALL ) return {ok: true, state: ${if (enable) ON else OFF}} } }注意两点一是权限判断必须放在执行函数里并且把权限缺失情况返回成结构化字符串而不是抛异常。客户端拿到NOTIFICATION_POLICY_ACCESS_DENIED后可以弹一个系统授权页。模型拿到这个错误也会在回答里如实说“打不开需要先授权”。二是工具执行结果不要只返回true或false要带一些上下文。返回{ok: true, state: ON}模型才能知道逻辑上发生了什么。4.3 不要把所有工具一次性塞给模型工具描述越多模型选择越容易出错token 消耗也越高。每个工具的描述看起来只有几十上百字但乘以 20 个工具一次请求就要多烧几千 token。所以我在系统里维护了一套工具标签机制。每次请求前按场景筛选工具用户提到“天气”只传 query_weather、set_do_not_disturb用户提到“会议/日程”只传 create_calendar_event、query_calendar兜底场景传三到五个常用工具。理想情况是同一轮请求的工具数量控制在 10 个以内。尤其是手机端跑本地小模型的时候这个限制不是洁癖而是能不能跑通的问题。5. 从一句大白话到真正执行完整请求闭环5.1 第一轮请求普通对话加上工具声明完整的工具调用循环并不比普通聊天多太多工作量但它是一个“请求-执行-回填-再请求”的循环。假设用户输入的是“北京明天要带伞吗顺便把勿扰模式打开。”第一轮请求需要组装消息列表和 toolsval messages mutableListOf( Message(role system, content SYSTEM_PROMPT), Message(role user, content 北京明天要带伞吗顺便把勿扰模式打开。) ) val requestBody buildChatRequestBody( messages messages, tools toolRegistry.specs() )SYSTEM_PROMPT 不用写太长但一定要包含几条硬规则你是一个手机助手。如果用户的要求可以被工具完成请先输出一次 tool_call。 如果工具执行失败请如实告诉用户失败原因并给出替代建议。 不要在没有工具结果的情况下假称操作已经完成。这三句话能避免很多“幻觉式成功”。模型因为天然倾向讨好用户即使它没有真正打开勿扰模式也可能回答“已经帮你打开了”。system prompt 里把这条堵死后面会少很多麻烦。5.2 解析响应tool_calls 藏在嵌套结构里第一轮请求返回后模型可能有两种情况它认为不需要工具直接返回普通文本它决定调用工具返回 assistant 消息并附带一个tool_calls数组。响应的大致结构是这样的OpenAI 兼容格式{ choices: [ { message: { role: assistant, content: null, tool_calls: [ { id: call_abc123, type: function, function: { name: query_weather, arguments: {\city\: \北京\, \days\: 1} } }, { id: call_def456, type: function, function: { name: set_do_not_disturb, arguments: {\enable\: true} } } ] } } ] }有几个点很容易踩tool_calls位于choices[0].message.tool_calls不是最外层。arguments是字符串不是 JSON 对象必须二次解析。id很重要后面回填 tool 结果时要原样携带。同一个响应里可能有多个tool_calls不要只取第一个。用 Gson 解析时我会写成这样val json JsonParser.parseString(raw).asJsonObject val messageObj json[choices].asJsonArray[0].asJsonObject[message].asJsonObject val content if (messageObj[content].isJsonNull) null else messageObj[content].asString val toolCalls mutableListOfToolCall() if (messageObj.has(tool_calls)) { messageObj[tool_calls].asJsonArray.forEach { elem - val obj elem.asJsonObject val fn obj[function].asJsonObject toolCalls.add( ToolCall( id obj[id].asString, name fn[name].asString, arguments fn[arguments].asString ) ) } }记住要判断content是否为 null因为一旦有 tool_callscontent 往往就是 null。5.3 执行工具并把结果回填给模型拿到 toolCalls 后按顺序逐个执行。第一版我强烈建议串行执行不要急着并发因为并发会引入执行顺序和上下文一致性问题。执行前一定要先把模型的 assistant 消息原样加回 messages 列表。这步一旦漏掉后面的会话顺序就乱了模型会看不懂“这个 tool 结果是给谁的”。// 1. 把 assistant 的原始消息加回上下文 messages.add( Message( role assistant, content content, toolCalls rawToolCalls ) ) // 2. 逐个执行工具生成 tool 消息 for (toolCall in toolCalls) { val args JsonParser.parseString(toolCall.arguments).asJsonObject val result toolRegistry.execute(toolCall.name, args) messages.add( Message( role tool