如果你也在做 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后,可以弹一个系统授权页。模型拿到这个错误,也会在回答里如实说“打不开,需要先授权”。
二是工具执行结果不要只返回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 第一轮请求:普通对话加上工具声明
完整的工具调用循环,并不比普通聊天多太多工作量,但它是一个“请求-执行-回填-再请求”的循环。
假设用户输入的是:
“北京明天要带伞吗?顺便把勿扰模式打开。”
第一轮请求需要组装消息列表和 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