Administrator
发布于 2026-08-25 / 38 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 78 篇:Android Function Calling:让 App 主动调用自己的 API

前言

RAG 让 LLM 能"读"你的资料,Function Calling 让 LLM 能"动手"——调用 App 里的具体 API。本篇聊端侧 LLM 的 Function Calling(也叫 Tool Use)能力,以及怎么在 Android 工程里把"识别调用"和"执行 API"做成可观测、可调试的链路。

一、Function Calling 在端侧 LLM 上能做什么

Function Calling 不是 LLM 在调函数,而是 LLM 在生成结构化输出(通常是 JSON),告诉业务代码"该调哪个函数、传什么参数"。App 端拿到 JSON,去执行真正的逻辑。

典型场景:

场景

LLM 决定调什么

用户说"看看上海天气"

get_weather(city="上海")

用户说"新建备忘:开会"

create_note(title="开会")

用户说"查询 12306"

search_train(from, to, date)

用户说"翻译上一条"

translate(text, target_lang)

LLM 不会真的访问网络或数据库,它只是"指出"——这是设计,安全和可控都需要在 App 端实现。

二、支持 Function Calling 的端侧 LLM

不是所有端侧模型都支持 Function Calling。当前可用:

模型

Function Calling

备注

Qwen2.5-1.5B-Instruct

支持

中文最佳,推荐

Qwen2.5-3B-Instruct

支持

更稳定

Llama 3.2 3B

支持

英文强,中文弱

Phi-3 Mini

不支持

需自己实现 tool use 格式

Gemma 2

不支持

需 hack

SmolLM2

不支持

推荐改用其他

多数国产 App 选 Qwen 系列,中文 Function Calling 准确率最高。

三、工具描述的 schema

LLM 怎么知道有哪些函数可调?通过系统 prompt:

val tools = """
[{
    "name": "search_train",
    "description": "查询火车票信息",
    "parameters": {
        "type": "object",
        "properties": {
            "from_city": { "type": "string", "description": "出发城市" },
            "to_city": { "type": "string", "description": "到达城市" },
            "date": { "type": "string", "description": "出发日期,格式 yyyy-MM-dd" }
        },
        "required": ["from_city", "to_city", "date"]
    }
}, {
    "name": "create_note",
    "description": "创建一条新的备忘录",
    "parameters": {
        "type": "object",
        "properties": {
            "title": { "type": "string", "description": "备忘录标题" },
            "content": { "type": "string", "description": "备忘录正文" }
        },
        "required": ["title"]
    }
}]
""".trimIndent()

描述一定要清晰——LLM 据此决定调不调、调哪个、传什么。"from_city" 比 "from" 更明确,"上海火车站" 比 "上海" 更细致。

四、提示词组装

Function Calling 在 Qwen 系列里有自己的格式:

val systemPrompt = """
你是用户助手,可以使用下列工具:

$tools

当用户提问时,输出严格 JSON 格式:
{
    "thought": "我决定调什么",
    "tool": "函数名",
    "args": { 函数参数 }
}
若无合适的工具,回答 { "thought": "...", "tool": "" }
""".trimIndent()

val userQuery = "查明天从北京到上海的高铁"
val fullPrompt = "$systemPrompt\n\n用户:$userQuery\n助手:"
val raw = llm.generateResponse(fullPrompt)
val decision = parseJson(raw)

Qwen 在 Function Calling 上有个大优点:它会输出"thought"字段解释为什么决定调这个——非常便于调试。

五、App 端执行逻辑

class ToolExecutor {
    suspend fun execute(tool: String, args: Map<String, Any>): String {
        return when (tool) {
            "search_train" -> {
                val from = args["from_city"] as String
                val to = args["to_city"] as String
                val date = args["date"] as String
                val result = trainApi.search(from, to, date)
                // 把结果转成自然语言,再喂给 LLM
                """
                ${date} 从 $from 到 $to 的车次:
                ${result.joinToString("\n") { "${it.no}  ${it.time}  ${it.price}" }}
                """.trimIndent()
            }
            "create_note" -> {
                val title = args["title"] as String
                val content = args["content"] as? String ?: ""
                notesRepo.create(Note(title, content))
                "已创建备忘:$title"
            }
            else -> "未知工具"
        }
    }
}

六、二次 prompt:把结果回传 LLM

工具执行后,需要把结果再次喂给 LLM,让它总结:

val toolResult = executor.execute(decision.tool, decision.args)

val followUpPrompt = """
$systemPrompt

用户:$userQuery
助手(JSON):${decision.toJson()}
工具结果:$toolResult
现在基于工具结果,用自然语言给用户最终回答。
助手:""".trimIndent()

val finalAnswer = llm.generateResponse(followUpPrompt)

这样模型会输出"为您查到明天北京南→上海虹桥的车次:G1 06:00 553元,……"。

七、安全设计

Function Calling 在 App 里是"半自动"——LLM 给出指令,但要执行什么、需不需要用户二次确认,由你决定。

7.1 工具分级

类型

示例

是否需要用户确认

查询类

查天气、查车次

否

创建类

写备忘、加日历

用户确认一次

修改/删除类

删除文件、转账

强校验 + 二次确认

物理类

拍照、打电话

高危 + 必须确认

实现:

suspend fun executeWithPermission(tool: String, args: Map<String, Any>): String {
    return when (toolRisk(tool)) {
        RiskLevel.LOW -> executor.execute(tool, args)
        RiskLevel.MEDIUM -> {
            val confirmed = showConfirm(tool, args)
            if (confirmed) executor.execute(tool, args) else "用户取消"
        }
        RiskLevel.HIGH -> {
            val passed = biometricAuth()
            if (passed) executor.execute(tool, args) else "需要鉴权"
        }
    }
}

7.2 速率限制

LLM 偶尔会"幻觉"——重复调用同一个工具。建议在 Executor 上加守卫:

private val callCounts = ConcurrentHashMap<String, AtomicInteger>()

fun tryCall(tool: String, args: Map<String, Any>): Boolean {
    val key = "$tool:$args"
    val count = callCounts.computeIfAbsent(key) { AtomicInteger(0) }
    return count.incrementAndGet() <= 3
}

八、性能与延迟

用户输入 → LLM 输出 JSON (~200ms) → API 调用 (~300ms) → LLM 总结 (~500ms)
                                                       ↓
                                                  用户看到答案
                                                    总: ~1s

主要时间花在 LLM 推理。如果首屏体验敏感,可以:

  • 直接展示 "正在为您查询..." 临时话术

  • 工具返回后,先展示原始 JSON 让用户看,LLM 总结后台完成

  • 工具结果缓存(同 city+date 的查询命中缓存,跳过 API)

九、调试与监控

// 工具调用日志
object ToolLogger {
    fun log(decision: ToolDecision, result: String, latency: Long) {
        val entry = """
        [ToolCall]
        Thought: ${decision.thought}
        Tool: ${decision.tool}
        Args: ${decision.args}
        Result: ${result.take(500)}
        Latency: ${latency}ms
        """.trimIndent()
        logger.info(entry)
    }
}

把每次 Function Call 落地到本地日志,分析:

  • 哪些工具被调最多

  • 有多少比例的幻觉(tool 名字不对、参数不合法)

  • 用户满意度(如果设计了"这条回答有用/没用"的反馈)

十、常见的"工具调用失败"模式

错误

表现

修法

JSON 格式错

LLM 输出非 JSON

加 try parse + retry 一次或换 prompt

工具名错

LLM 编了一个不存在的 tool

限定 enum 取值或加 schema 验证

参数类型错

数字传字符串

在 API 层做转换

工具描述模糊

LLM 调错函数

改 description,明确每个工具的区别

多轮调用

一个目标需要多次调用

设计状态机框架,支持多轮

小结

Function Calling 是 LLM 与 App 集成的"动静结合"最佳模式。LLM 负责意图理解与决策,App 负责执行与安全。落地时把工具描述写清楚、把安全门槛做实、把监控打点齐全,是三件不能跳的事。

下一篇聊 ML Kit——Google 的端侧 AI 推理框架,包括人脸识别、文本识别、姿势检测等"开箱即用"能力。

参考阅读

  • Qwen2.5 Function Calling Guide

  • Android ML Kit 文档

  • OpenAI Function Calling 设计(虽然云端,但启发性强)


评论