让 Android 里的 AI 真正「干活」:Function Calling 工程实现全解

从"能聊天"到"能干活"

我记得第一次把 LLM 接入 Android App 的时候,整个人是很兴奋的。用户输入一句话,模型回一段文字,感觉很神奇。但兴奋劲过去之后,我开始觉得:这玩意儿就是个聪明的文字游戏机。

用户问"帮我查一下今天天气",模型说"好的,今天天气......"------然后一本正经地胡编一个天气出来。用户问"帮我设个明天早八的闹钟",模型回复"已设置完毕"------什么都没发生。

这就是 LLM 接入的第一道墙:它只会说话,不会干活

Function Calling(也叫 Tool Use)就是用来打破这道墙的。核心思路很简单:告诉模型它有哪些"工具"可以用,模型在需要的时候不直接回复用户,而是返回一个结构化的"我要调用这个工具,参数是这些"------然后由 App 代码真正去执行,再把执行结果喂回模型,模型最终给用户一个有意义的答复。

听起来很简单,但工程上有一堆细节。今天从头到尾把这套机制在 Android 端怎么落地讲清楚。

Function Calling 的协议长什么样

先把基础概念说清楚,不然后面写代码会晕。

以 OpenAI 的协议为例(国内大部分模型都兼容这套),整个流程是这样的:

第一轮:告诉模型有哪些工具

在第一次请求里,除了 messages,你还要附带一个 tools 列表,描述每个工具的名字、作用、参数结构(JSON Schema):

复制代码
{
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "获取指定城市的实时天气",
        "parameters": {
          "type": "object",
          "properties": {
            "city": {
              "type": "string",
              "description": "城市名,如:北京"
            }
          },
          "required": ["city"]
        }
      }
    }
  ]
}

第二轮:模型返回工具调用意图

如果模型判断需要调用工具,它的响应不是普通文字,而是:

复制代码
{
  "finish_reason": "tool_calls",
  "message": {
    "role": "assistant",
    "tool_calls": [
      {
        "id": "call_abc123",
        "type": "function",
        "function": {
          "name": "get_weather",
          "arguments": "{\"city\":\"北京\"}"
        }
      }
    ]
  }
}

第三轮:把执行结果喂回去

App 收到这个响应后,真正调用天气 API,拿到结果,再作为 tool role 的 message 追加到对话历史,然后再发一次请求给模型。模型这才给出最终的用户可见回复。

📌 注意 arguments 是一个 JSON 字符串(不是对象),而且模型有时候会输出非法 JSON。这个坑我后面会提到。

Android 端的数据模型设计

协议搞清楚了,开始写代码。先定义数据结构,这部分设计好了后面会省很多麻烦。

复制代码
// 工具定义
data class ToolDefinition(
    val name: String,
    val description: String,
    val parameters: JsonObject
)

// 对话消息
sealed class ChatMessage {
    data class User(
        val content: String
    ) : ChatMessage()

    data class Assistant(
        val content: String? = null,
        val toolCalls: List? = null
    ) : ChatMessage()

    data class ToolResult(
        val toolCallId: String,
        val content: String
    ) : ChatMessage()
}

data class ToolCall(
    val id: String,
    val name: String,
    val argumentsJson: String
)

注意 AssistantcontenttoolCalls 都是可空的。当模型决定调用工具时,content 通常为空;当直接回复时,toolCalls 为空。这两种情况对应不同的后续处理逻辑。

Tool 注册与分发机制

工具多了之后,你不可能把所有执行逻辑都堆在一个地方。我习惯用一个注册表 + 接口的模式来管理:

复制代码
// 工具接口
interface Tool {
    val definition: ToolDefinition
    suspend fun execute(
        args: JsonObject
    ): String
}

// 工具注册表
class ToolRegistry {
    private val tools =
        mutableMapOf()

    fun register(tool: Tool) {
        tools[tool.definition.name] = tool
    }

    fun getDefinitions(): List =
        tools.values.map { it.definition }

    suspend fun dispatch(
        call: ToolCall
    ): String {
        val tool = tools[call.name]
            ?: return "Error: tool not found"
        return try {
            val args = Json.parseToJsonElement(
                call.argumentsJson
            ).jsonObject
            tool.execute(args)
        } catch (e: Exception) {
            "Error: ${e.message}"
        }
    }
}

实现一个具体的工具很直观,比如获取天气:

复制代码
class WeatherTool(
    private val weatherApi: WeatherApi
) : Tool {

    override val definition = ToolDefinition(
        name = "get_weather",
        description = "获取指定城市的实时天气信息",
        parameters = buildJsonObject {
            put("type", "object")
            putJsonObject("properties") {
                putJsonObject("city") {
                    put("type", "string")
                    put("description",
                        "城市名称")
                }
            }
            putJsonArray("required") {
                add("city")
            }
        }
    )

    override suspend fun execute(
        args: JsonObject
    ): String {
        val city = args["city"]
            ?.jsonPrimitive?.content
            ?: return "缺少城市参数"
        val result = weatherApi
            .getWeather(city)
        return "${city}当前天气:
    ${result.condition},
    ${result.temperature}°C"
    }
}

核心对话循环:AgentLoop

这是整个系统的心脏。有了工具注册表,我们需要一个"对话调度器"来处理多轮工具调用。我管它叫 AgentLoop:

复制代码
class AgentLoop(
    private val llmClient: LlmClient,
    private val registry: ToolRegistry
) {
    suspend fun run(
        userMessage: String,
        history: List = emptyList()
    ): Flow = flow {

        val messages = mutableListOf()
        messages.addAll(history)
        messages.add(ChatMessage.User(
            userMessage
        ))

        val maxRounds = 10
        var round = 0

        while (round++  {
                    messages.add(
                        ChatMessage.Assistant(
                            toolCalls = response.toolCalls
                        )
                    )
                    for (call in
                        response.toolCalls) {
                        emit(AgentEvent
                            .ToolCalling(
                                call.name
                            ))
                        val result =
                            registry.dispatch(call)
                        messages.add(
                            ChatMessage
                                .ToolResult(
                                toolCallId = call.id,
                                content = result
                            )
                        )
                        emit(AgentEvent
                            .ToolDone(
                                call.name, result
                            ))
                    }
                }
                // 模型直接回复,结束
                else -> {
                    emit(AgentEvent
                        .FinalAnswer(
                            response.content ?: ""
                        ))
                    return@flow
                }
            }
        }
        // 超出最大轮次
        emit(AgentEvent.Error(
            "exceeded max rounds"
        ))
    }
}

用 Flow 发出事件有个好处:UI 可以实时展示"正在调用天气工具..."这种中间状态,而不是傻等最终答案。

AgentEvent 的定义:

复制代码
sealed class AgentEvent {
    data class ToolCalling(
        val toolName: String
    ) : AgentEvent()

    data class ToolDone(
        val toolName: String,
        val result: String
    ) : AgentEvent()

    data class FinalAnswer(
        val content: String
    ) : AgentEvent()

    data class Error(
        val message: String
    ) : AgentEvent()
}

那些真实踩过的坑

坑一:arguments 不是合法 JSON

这个坑我踩过好几次。模型有时候会输出这样的 arguments:

复制代码
{city: "北京"}          // key 没加引号
{"city": "北京",}       // 尾逗号
{"city": '北京'}        // 单引号

解决方法:在 dispatch 之前做一次容错解析。可以先尝试标准 JSON,失败了再用宽松模式(比如用 Gson 的 lenient 模式,或者简单的正则修复):

复制代码
fun parseArgsLenient(
    raw: String
): JsonObject {
    return try {
        Json.parseToJsonElement(raw)
            .jsonObject
    } catch (e: Exception) {
        // 尝试用 Gson lenient 解析
        val gson = GsonBuilder()
            .setLenient().create()
        val map = gson.fromJson(
            raw,
            Map::class.java
        )
        buildJsonObject {
            map.forEach { (k, v) ->
                put(k.toString(),
                    v.toString())
            }
        }
    }
}

坑二:并行工具调用的顺序问题

有些模型支持一次返回多个 tool_calls,理论上可以并行执行。但我建议不要并行,原因有两个:

第一,某些工具之间有依赖关系(先查用户信息,再更新);第二,并行执行时如果某个工具失败,你的 messages 历史会出现"洞"(有些 tool_result 没有对应的 tool_call id),模型下一轮可能会困惑。

所以我在 AgentLoop 里用了串行 for 循环,安全第一。

坑三:工具执行异常不能让整个对话崩掉

一个工具调用失败(网络超时、API 报错),不应该让整个 AgentLoop 抛异常。正确的做法是:catch 异常,把错误信息作为 tool result 返回给模型,让模型自己决定怎么处理(重试、告知用户、换个方式):

复制代码
val result = try {
    registry.dispatch(call)
} catch (e: Exception) {
    // 把错误信息告诉模型
    "工具调用失败: ${e.message}"
}

这样模型会回复用户"抱歉,获取天气失败,请稍后再试",而不是 App 直接崩。

坑四:系统权限工具要小心

调用系统 API(设闹钟、发通知、读联系人)需要在 manifest 里声明权限,但模型不知道这回事。它可能在用户没授权的情况下就发出 tool_call,然后你的工具执行失败。

正确做法:在工具的 execute 里先检查权限,如果没有就返回"需要用户授权 xxx 权限"。然后 App 层收到 ToolCalling 事件时,可以在 UI 上提示用户授权,授权完成后再触发一次 AgentLoop。

UI 层怎么接

有了 AgentLoop,ViewModel 层的写法很清晰:

复制代码
@HiltViewModel
class ChatViewModel @Inject constructor(
    private val agentLoop: AgentLoop
) : ViewModel() {

    private val _uiState =
        MutableStateFlow(ChatUiState())
    val uiState = _uiState
        .asStateFlow()

    fun sendMessage(text: String) {
        viewModelScope.launch {
            _uiState.update {
                it.copy(isLoading = true)
            }
            agentLoop.run(
                userMessage = text,
                history = _uiState.value.history
            ).collect { event ->
                when (event) {
                    is AgentEvent.ToolCalling ->
                        showToolStatus(
                            "正在调用 
                ${event.toolName}..."
                        )
                    is AgentEvent.FinalAnswer ->
                        appendMessage(
                            event.content
                        )
                    is AgentEvent.Error ->
                        showError(event.message)
                    else -> {}
                }
            }
            _uiState.update {
                it.copy(isLoading = false)
            }
        }
    }
}

UI 能实时看到"正在调用天气工具..."这类提示,体验会好很多。用户知道 AI 在干活,不会以为卡住了。

端侧 vs 云端:工具调用的选择

最后聊聊一个常见的决策问题:用端侧模型还是云端模型做 Function Calling?

坦白说,现在端侧小模型的工具调用能力和云端大模型还有明显差距。我测试过几个端侧模型(Qwen2.5-1.5B、Gemma3-2B),在工具参数解析的准确率上,遇到稍微复杂的场景就容易出错。

我个人的建议:

如果你的工具调用逻辑复杂(多工具链、参数嵌套),优先用云端 API。如果工具简单(固定的 1-2 个工具,参数结构清晰),可以尝试端侧,但要做充分的容错兜底。

端侧的优势在于隐私和延迟,这两点在某些场景下是刚需。随着模型能力的提升,这个结论可能在一两年内就会变。我自己也在持续跟进 Qwen2.5-7B 和 Gemma3 在 Function Calling 上的表现,等有更多实测数据再写一篇。

如果你在项目里落地了 Function Calling,欢迎留言聊聊遇到了哪些奇葩问题------我怀疑大家踩的坑有很多重叠的。

相关推荐
我命由我1234513 分钟前
Android 开发中,关于 Gradle 的 distributionUrl 的一些问题
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
su_ym811020 分钟前
Android 系统源码阅读与编译构建实战指南
android·framework
方白羽1 小时前
《被封印的六秒:大厂外包破解 Android 启动流之谜》
android·app·android studio
CoderJia程序员甲1 小时前
GitHub 热榜项目 - 日榜(2026-04-16)
ai·大模型·github·ai教程
追巨1 小时前
H200 安装驱动并使用sglang启动模型
ai·模型部署
jasonblog1 小时前
对小龙虾openclaw的关注、学习、使用和变化观察
人工智能·学习·ai
慕峯2 小时前
反蒸馏 Skill 安装使用教程
ai
IT乐手2 小时前
java 对比分析对象是否有变化
android·java
做时间的朋友。3 小时前
MySQL 8.0 窗口函数
android·数据库·mysql
举儿3 小时前
通过TRAE工具实现贪吃蛇游戏的全过程
android