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

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

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

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

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

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

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

Function Calling 的协议长什么样

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

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

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

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

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

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

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

swift 复制代码
{
  "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 端的数据模型设计

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

kotlin 复制代码
// 工具定义
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 注册与分发机制

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

kotlin 复制代码
// 工具接口
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}"
        }
    }
}

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

kotlin 复制代码
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:

kotlin 复制代码
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 的定义:

kotlin 复制代码
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:

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

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

kotlin 复制代码
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 返回给模型,让模型自己决定怎么处理(重试、告知用户、换个方式):

kotlin 复制代码
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 层的写法很清晰:

kotlin 复制代码
@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?

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

我个人的建议:

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

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

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

相关推荐
mumuWorld2 小时前
解决openclaw以及插件安装的报错
前端·ai编程
GISer_Jing2 小时前
前端组件库——shadcn/ui:轻量、自由、可拥有,解锁前端组件库的AI时代未来
前端·人工智能·ui
执行部之龙2 小时前
JS手写——call bind apply
前端·javascript
京东零售技术2 小时前
告别手动搬砖: JoyCode + i18n-mcp 实现前端项目多语言自动化
前端
李少兄2 小时前
企业资源计划(ERP)系统全景指南
java·前端·数据库·erp
张一凡932 小时前
React 项目也能用依赖注入?我尝试了一下,真香
前端·react.js
somebody2 小时前
零经验学 react 的第15天 - 过渡动画(使用 react-transition-group 库进行实现)
前端
SuperEugene2 小时前
Vue3 + Element Plus 表单开发实战:防重复提交、校验、重置、loading 统一|表单与表格规范篇
前端·javascript·vue.js
SuperEugene2 小时前
Vue3 + Element Plus 中后台弹窗规范:开闭、传参、回调,告别弹窗地狱|Vue 组件与模板规范篇
开发语言·前端·javascript·vue.js·前端框架