十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android端Agent接入大模型实战:从裸调到Function Calling的完整落地

Android端Agent接入大模型实战:从裸调到Function Calling的完整落地 如果你的认知还停留在“在Android Studio里写个网络请求、把用户问题POST给大模型、再把返回值解析出来显示到TextView”那这篇文章可能会让你改观。我最早接触“Android Studio Agent 接入大模型”这个方向时也以为就是把OpenAI兼容接口的URL换成自己的API Key结果项目跑到一半就卡住了先是一调接口就ANR后来流式输出中文乱码再后来多轮对话越聊越贵最后发现Agent所谓“工具调用”根本不是单纯拼一个JSON字段那么简单。这篇博文不讲虚的直接说清楚三件事为什么Android端接大模型要做成Agent而不是裸调接口、Agent的完整闭环在安卓端怎么用代码落地、以及我在实战中踩过的坑和处理方案。如果你正准备给App接入大模型能力或者想搞明白“Agent”这个热词在移动端到底意味着什么这篇内容应该能帮你少走不少弯路。1. 为什么Android端接Agent不是“拼个URL”那么简单1.1 我的第一个版本是怎么翻车的一开始我写的“Agent”本质上是个聊天机器人外壳OkHttp发请求JSON解析RecyclerView展示。用户问“帮我查一下北京的天气”我就把这句话原封不动发给大模型。第一次跑通的时候挺兴奋因为模型确实能回答“北京今天晴23度”。但仔细一看就发现问题了这个回答是模型基于训练数据猜的不是实时的真实天气。用户接着问“那上海呢”它也能答但答案大概率是编的。这时候我才意识到用户要的不是一个能聊天的玩具而是一个能按照意图去调用真实能力的Agent。也就是说大模型应该负责“理解意图”而不是“生产事实”——生产事实这件事应该交给真实的工具接口去做比如天气API、日历API、备忘录API。1.2 Agent到底是什么和普通接口调用差在哪Agent在移动端的本质是一个“大模型做大脑、工具做手脚”的执行循环。普通接口调用是线性的用户提问你调一个API拿到结果结束。但Agent是循环的用户提出需求大模型判断要不要调用工具、调用哪个工具、传什么参数客户端执行工具拿到结果把工具结果回传给大模型大模型基于工具结果生成最终回复。所以“Tool Calling”或者叫“Function Calling”就是Agent的枢纽。没有这个机制大模型再聪明也只是一个“知道很多但什么都做不了”的聊天窗口有了它大模型才能在你的App里“动手干活”。1.3 三种接入方式对比裸调、Stream模式、Agent编排我整理了一张表方便你判断自己的项目到底需要哪种方案别一上来就上Agent复杂度会吓退你方案适合场景优点缺点裸调API非流式简单的问答、文案生成按钮实现最快代码最少响应慢体验差无法做工具调用流式APISSE需要打字机效果的对话界面体验好首字延迟低解析复杂度上升重试逻辑麻烦Agent编排Function Calling需要调用App本地能力、第三方API的智能助手能完成真实任务状态管理复杂token消耗更高我的建议是如果你的需求只是“点一下按钮生成一段文案”裸调就够用如果你做的是聊天机器人至少上流式如果你希望这个助手能“执行任务”比如设置提醒、查天气、控制设备那才值得上Agent编排。2. 工程准备依赖、网络安全与密钥管理的几个细节2.1 开发环境与依赖清单这次实战我用的环境如下都是当前比较主流的版本你可以对照参考Android StudioKoala或以上版本JDK 17语言Kotlin 1.9协程建议用1.7版本网络库OkHttp 4.12 Retrofit 2.9你也可以只用OkHttp我后面会讲为什么Retrofit在这个场景不是必须的JSON解析Moshi或Gson我这里用的Moshi对Kotlin的可空性支持更好构建配置AGP 8.2以上compileSdk 34。依赖就这些不需要引入额外的Agent框架。我建议你在第一个版本里自己手写Agent循环因为理解原理远比引入框架重要。等你自己写过一遍再去看LangChain、AutoGen那类框架才会真正读懂它们的设计意图而不是照猫画虎。2.2 Android 9以上默认禁止明文HTTP怎么处理如果你用的是本地大模型比如Ollama、LM Studio或者调试阶段连的是内网HTTP服务一个非常容易踩的坑就是Android 9API 28之后默认禁止明文HTTP流量直接请求会抛Cleartext HTTP traffic not permitted。调试阶段有两个解决方案方案一临时在AndroidManifest.xml的application标签里加android:usesCleartextTraffictrue简单粗暴适合本地联调。方案二更规范的做法是配网络安全配置文件只允许特定域名走明文HTTP?xml version1.0 encodingutf-8? network-security-config domain-config cleartextTrafficPermittedtrue !-- 模拟器访问宿主机 -- domain includeSubdomainstrue10.0.2.2/domain !-- 局域网内的大模型服务 -- domain includeSubdomainstrue192.168.0.0/domain /domain-config /network-security-config注意10.0.2.2是Android模拟器访问宿主机的固定地址。如果你连接的是物理机上的Ollama在模拟器里把BaseUrl配成http://10.0.2.2:11434/v1就能通。这是很多人第一次连本地模型连不上时最容易忽略的原因。2.3 API Key不要写进代码里我在早期项目里踩过一个特别蠢的坑把API Key直接写在MainActivity.kt里然后代码推到Git仓库。第二天就收到一条短信说我的密钥被公开扫描到了已经轮换并产生了费用。正确做法是把密钥放到local.properties文件这个文件不会进Git然后在build.gradle.kts里读取通过BuildConfig注入// app/build.gradle.kts import java.util.Properties import java.io.FileInputStream val localProperties Properties() val localFile rootProject.file(local.properties) if (localFile.exists()) { localProperties.load(FileInputStream(localFile)) } android { buildFeatures { buildConfig true } defaultConfig { buildConfigField( String, LLM_API_KEY, \${localProperties.getProperty(LLM_API_KEY, )}\ ) buildConfigField( String, LLM_BASE_URL, \${localProperties.getProperty(LLM_BASE_URL, https://api.example.com/v1)}\ ) } }代码里直接引用BuildConfig.LLM_API_KEY。还有个细节正式发布的应用里密钥如果用于调用付费大模型接口服务端必须做一层中转不能把密钥下发到客户端。客户端持有密钥等于把这笔账算在用户头上而且随时可能被盗刷。如果这个App是给别人用的一定要走后端代理。3. 吃透Chat协议messages结构与流式SSE解析3.1 messages数组就是Agent的全部记忆载体不管是OpenAI兼容接口还是OllamaChat接口的请求体核心都是一个messages数组。这个数组里每个元素都有role字段常见的有四种system系统指令告诉模型“你是我的App助手回答要简洁”user用户输入assistant模型的回复tool工具执行结果回传给模型。很多人没意识到messages数组就是Agent唯一的外部记忆。它有两个直接影响第一每次请求都要把历史消息完整带上模型才能记得住之前的对话第二随着对话变长token成本线性上涨。所以后文讲的“滑动窗口压缩摘要”就是围绕这个数组做文章。3.2 一次普通对话的完整请求响应以OpenAI兼容接口为例一个最简单的非流式请求长这样{ model: gpt-4o-mini, messages: [ {role: system, content: 你是一个Android助手回答要简洁。}, {role: user, content: 帮我写一段Kotlin协程示例} ], temperature: 0.7 }响应里最关键的是choices[0].message.content。但当你开启工具调用后响应里可能出现tool_calls字段这就意味着模型不打算直接回答而是想调用工具。我建议你先把普通对话跑通再上工具调用否则排查问题时不知道是网络问题、协议问题还是Agent逻辑问题。3.3 流式SSE的中文乱码与粘包问题流式模式下接口返回的不是一次性JSON而是一串以data:开头的事件流每条事件之间用空行分隔结束标记是data: [DONE]。我在实战里遇到两个典型问题第一个是中文乱码。OkHttp读取流时如果没用对字符集很容易把UTF-8的中文解析成乱码。务必指定source的字符集为UTF-8或者直接用ResponseBody.charStream()。第二个是粘包。一条事件可能被拆成多段到达或者多条事件在一次读取里同时到达。如果简单的readLine处理偶尔会解析失败。稳妥做法是按\n\n分隔符拆事件再对每条事件单独判断是否以data:开头val source response.body?.source() ?: return while (!source.exhausted()) { val line source.readUtf8Line() ?: break if (!line.startsWith(data:)) continue val data line.removePrefix(data:).trim() if (data [DONE]) break // 解析 data 中的 JSON 片段 val json Json.parseToJsonElement(data).jsonObject val delta json[choices]?.jsonArray ?.firstOrNull()?.jsonObject ?.get(delta)?.jsonObject val content delta?.get(content)?.jsonPrimitive?.contentOrNull if (!content.isNullOrEmpty()) { callback.onDelta(content) } }注意delta和完整返回的message结构不同流式返回的是增量内容而不是全量文本。我第一版没搞清楚这个区别傻傻地每次把整个delta当作新消息塞进消息列表结果UI上出现大量重复内容。4. Agent循环的核心Function Calling让大模型“动手干活”4.1 工具描述的JSON Schema要让大模型知道你的App有哪些工具可用你需要在请求里附带一个tools数组每个工具都符合JSON Schema格式。以一个查询天气的工具为例{ type: function, function: { name: query_weather, description: 查询指定城市的实时天气当用户询问天气时必须调用这个工具, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海、广州 } }, required: [city] } } }这个描述非常关键。description写得好不好直接决定了大模型能不能准确地把用户的话映射到正确的工具上。我试过把描述写得很笼统比如“查询天气”结果模型经常不知道该在什么时候调用后来把描述改细加上触发条件“当用户询问天气时”准确率明显提升。这背后的逻辑是模型是靠语义匹配来决策的描述里的触发词越明确匹配越准。4.2 一次工具调用的完整消息序列当模型决定调用工具时它不会立刻给你最终答案而是返回一个tool_calls数组。此时你的Agent循环必须做以下几件事把这条带tool_calls的assistant消息追加进历史消息数组根据tool_calls里的函数名和参数在本地执行对应函数把执行结果包装成一条role: tool的消息追加进历史带着完整的消息数组再次请求大模型。完整的消息序列长这样user: 北京今天需要带伞吗 assistant: tool_calls[query_weather(city北京)] tool: {temperature: 26, condition: 小雨, humidity: 80} assistant: 北京今天有小雨出门建议带伞气温26度左右。关键点在第二步和第四步之间执行完工具之后不要用工具结果直接当最终答案而是必须把结果回传给大模型让它组织语言。很多初学者容易犯的错是工具返回啥就原封不动显示啥这么做Agent就没意义了——你要的是模型基于工具结果生成人话。4.3 循环上限、超时与容错逻辑Agent循环不是无限循环。一次用户请求可能触发连续多次工具调用比如“帮我规划明天上午的行程”可能需要先查日程、再查天气、再调地图。但必须设置循环上限我通常设为3次防止模型陷入死循环或者被恶意输入带偏。很典型的翻车场景是模型反复调用同一个工具比如连续调了三次查询天气但参数都一样。所以我还会加一道去重判断如果模型连续两次调用同一个工具且参数相同直接中断循环提示用户“这个请求我处理不了”。另外工具执行本身也可能失败。比如网络断了、参数解析失败、工具抛异常。这时不要假装成功而要把错误信息作为tool消息回传给模型让它自行决定是重试还是向用户解释。举个例子工具返回{error: city_not_found}模型可能会回“抱歉我没找到这个城市的信息请确认城市名是否正确”。这种容错能力是Agent和普通接口最大的区别之一。5. Android端落地状态管理、流式UI与上下文裁剪5.1 ViewModel和协程怎么组织Android端的Agent状态和界面状态必须分离。我会在ViewModel里维护两类状态AgentState会话历史即messages数组、当前Agent循环状态、工具注册表UiStateUI列表数据、加载状态、流式输出进度。协程方面Agent循环是一个耗时的IO操作必须在viewModelScope.launch里用Dispatchers.IO跑。但工具调用里有部分操作需要切回主线程比如读数据库、刷新UI我用withContext精确控制切换范围尽量不要在IO线程操作UI。一个容易被忽视的点是取消。用户可能在流式输出过程中退出界面或点击“停止”这时候必须取消协程同时奥丢掉当前积累的增量内容。不要试图恢复半截的流最简单可靠的方案是取消后重新开始并提示用户“已停止生成”。5.2 流式消息的RecyclerView增量渲染流式输出到UI核心思路是“占位一条空消息然后持续往里追加内容”。我在RecyclerView里维护的列表结构是这样用户消息和assistant消息是两条独立itemassistant消息在开始流式输出前先插入一个空内容的item然后每个onDelta回调都往这个item的内容上追加文本。如果整个消息列表频繁用notifyDataSetChanged列表会闪烁而且长文本时性能很崩。我实测下来最有效的方案是持有正在流式输出的那个item的position在onDelta里更新内存中的对象再调用notifyItemChanged(position)。如果你的列表item高度会随内容变化配合RecyclerView.setHasFixedSize(false)避免测量错乱。另外就是自动滚动到底部。流式输出时每来一段内容用户希望看到最新文本。在onDelta里判断列表是否接近底部如果接近才平滑滚动到底部不要无脑滚动否则用户在往上翻历史记录时会被强制拽下去体验很糟。5.3 上下文滑动窗口省token又能保持记忆的折中方案对话长了以后把所有历史都塞进messages数组会让token成本飙升而且模型可能被陈旧信息干扰。我采用的方案是滑动窗口加摘要保留最近N轮比如10轮完整消息超过N轮的部分用模型做一次摘要压缩再作为一条system消息放在最前面。具体做法是当历史消息超过阈值时调一次大模型让它对这N轮之前的对话做总结比如“用户之前询问了北京天气带伞结论是小雨”然后把总结放进system消息中。这是一个卸磨杀驴的操作——但它确实有效既保留了关键信息又控制了token量。不过要注意频率控制。摘要本身也是调模型也会花token。我设定只在超过20轮时触发摘要而且同一会话摘要最多做两次避免摘要嵌套。6. 实战排障从ANR到乱码的典型问题处理记录6.1 主线程卡死与ANR问题这是我最早遇到也最典型的错误。第一版代码我直接在onClick里发网络请求结果模型返回快一点还好稍微慢一点就ANR。根因Android主线程不能做网络IO这是铁律但它不只是规则问题更是性能问题。主线程一旦阻塞界面无法绘制用户会感觉App“卡死”。修复方案就是走协程。我用的模板长这样viewModelScope.launch { _uiState.value Loading try { val result agent.execute(userInput) _uiState.value Success(result) } catch (e: IOException) { _uiState.value Error(网络异常请检查连接) } catch (e: Exception) { _uiState.value Error(处理失败${e.message}) } }注意agent.execute里面所有网络调用、工具执行都必须在IO调度器上只有UI更新回主线程。6.2 流式输出乱码、截断与重试乱码问题除了字符集还有一个隐蔽来源分块编码。有些服务端在返回SSE流时用的是Transfer-Encoding: chunkedOkHttp默认支持但如果你手动用BufferedReader读取可能因为缓冲区大小导致数据截断。我遇到过一种情况长回答输出到一半突然中断且没有收到[DONE]标记也没有异常。排查后发现是服务端主动断开了连接。这时候客户端不能傻等要自己兜底如果流式输出中途断开且内容不完整就把已经收到的文本暂存然后自动向服务端发起一次重试请求上下文继续之前的内容。这样用户几乎感知不到异常只是会发现回复分成了两段。重试逻辑要加阈值不能无限重试。我设定最多重试2次间隔指数退避。另外如果断流时已经输出了比较长的文本就不重试了直接把已生成的内容当作最终结果展示再追加一句提示“内容已截断请重试或精简问题”。这样做是成本最低的取舍。6.3 token成本失控与模型选型取舍Agent比普通聊天消耗更多token因为每一次工具调用都要把完整的历史消息和工具定义重新发送一遍。我做过一个粗略统计一次带两次工具调用的天气查询消耗的token大约是同问题直接问答的4到5倍。所以模型选型要讲究梯度策略简单意图识别、工具路由用小参数模型速度快还便宜复杂推理、工具结果总结用大参数模型质量才稳如果使用本地模型Ollama方案优先考虑量化和参数量适中的模型比如7B到14B的Q4量化版本在手机端调试时既能跑动效果也在线。我做了一个按场景选型的对照供你参考场景模型规模说明意图识别、工具路由小模型如7B或Mini系列响应快token消耗低多轮对话、内容生成中大型模型如14B/32B或云端旗舰回答质量更重要工具结果总结中模型兼顾质量和延迟离线/隐私敏感场景本地Ollama小模型数据不出设备但能力有限另外还有一个容易忽视的成本工具定义的JSON Schema本身也占token。每个工具定义几百到几千token不等。如果你注册了20个工具每次请求光工具定义就吃掉不少预算。优化方法是不要把工具一股脑全塞进去而是做一层粗粒度路由先让模型判断意图归到哪个分类再只塞该类别的工具定义。这个优化做下来我的token消耗降低了差不多一半。Android端接大模型的实战内容大体就是这些。路径并不复杂工程准备、协议建模、Agent循环、UI落地、稳定性优化每一环都有它自己的坑。我做完这个项目最大的体会就是Agent的真正难点不在“调通接口”而在“让模型和人之间配合得顺滑”——你既要理解模型的脾性也要照顾Android系统的纪律两边都得兼顾。你如果正在做类似项目建议先从最简单的裸调开始在界面和状态管理稳定之后再一步步把Function Calling、工具回传、上下文压缩加进去。移动端Agent这条路才刚刚开始后面值得折腾的事情还有很多。
返回列表