从"能聊天"到"能干活"
我记得第一次把 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
)
注意 Assistant 里 content 和 toolCalls 都是可空的。当模型决定调用工具时,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,欢迎留言聊聊遇到了哪些奇葩问题------我怀疑大家踩的坑有很多重叠的。