Android AI应用:用Function Calling让大模型真正执行系统操作
2026/9/9 7:02:16 网站建设 项目流程

如果你也在做 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:name="android.permission.INTERNET" />

如果你在本地测试连的是 HTTP 明文地址,还需要在<application>标签上打开明文流量:

<application android:usesCleartextTraffic="true" ... >

本地调试可以这样,生产环境建议用 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 管理每个工具的两张脸

我把每个可以调用的能力抽象成ToolExecutor

interface ToolExecutor { // 给模型看的声明,包括函数名、描述、参数 JSON Schema fun toolSpec(): ToolSpec // 真正执行,入参是模型返回的参数 JSON,出参是回传给模型的字符串 fun execute(args: JsonObject): String }

为什么必须把“声明”和“执行”拆开?因为给模型看的描述不是从代码自动生成的。描述怎么写,直接决定模型什么时候调用、参数填得对不对。

接下来用一个注册表管理所有工具,避免在每个调用的地方写when(name)

class ToolRegistry { private val executorMap = mutableMapOf<String, ToolExecutor>() fun register(executor: ToolExecutor) { executorMap[executor.toolSpec().name] = executor } fun specs(): List<ToolSpec> = 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: integer
set_do_not_disturb用户说开启勿扰、勿扰模式、会议中不想被打扰;无参数时默认开启enable: boolean
create_calendar_event用户在“帮我记一下”“明天下午三点开会”时调用title: string; startTime: string; endTime?: string

其中 set_do_not_disturb 的实现里,我会调用系统的 NotificationManager:

class 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后,可以弹一个系统授权页。模型拿到这个错误,也会在回答里如实说“打不开,需要先授权”。

二是工具执行结果不要只返回truefalse,要带一些上下文。返回{"ok": true, "state": "ON"},模型才能知道逻辑上发生了什么。

4.3 不要把所有工具一次性塞给模型

工具描述越多,模型选择越容易出错,token 消耗也越高。每个工具的描述看起来只有几十上百字,但乘以 20 个工具,一次请求就要多烧几千 token。

所以我在系统里维护了一套工具标签机制。每次请求前按场景筛选工具:

  • 用户提到“天气”,只传 query_weather、set_do_not_disturb;
  • 用户提到“会议/日程”,只传 create_calendar_event、query_calendar;
  • 兜底场景传三到五个常用工具。

理想情况是同一轮请求的工具数量控制在 10 个以内。尤其是手机端跑本地小模型的时候,这个限制不是洁癖,而是能不能跑通的问题。

5. 从一句大白话到真正执行:完整请求闭环

5.1 第一轮请求:普通对话加上工具声明

完整的工具调用循环,并不比普通聊天多太多工作量,但它是一个“请求-执行-回填-再请求”的循环。

假设用户输入的是:

“北京明天要带伞吗?顺便把勿扰模式打开。”

第一轮请求需要组装消息列表和 tools:

val 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 = mutableListOf<ToolCall>() 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_calls,content 往往就是 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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询