前段时间折腾了一个让我自己挺兴奋的项目:把一个功能几乎完整的 AI Agent 装进了 iPhone 和 iPad,不是那种只套个网页壳的 Demo,而是能在系统级别调用工具、记住上下文、自己规划任务、独立跑完整个流程的 Agent。今天把这套方案的选型思路、技术细节和踩坑记录完整梳理出来,希望对想在移动端跑 Agent 的朋友有点帮助。
想先说明白一个前提:这篇文章讲的“完整 Agent”,指的是具备“感知输入 -> 任务规划 -> 工具调用 -> 记忆读写 -> 结果输出”完整闭环的系统,而不是聊天窗口加一个流式输出。真正的 Agent 要能自己决定下一步做什么,而不是每次都由人来指挥。
这个项目从动手到跑通,我大概花了两周左右,中间推翻过两次方案。一次是因为客户端太重,一次是因为工具调用链路断了。下面我会把最终跑通的方案按设计思路、核心组成、实操实现和问题排查四个部分展开讲,每一部分都会带上具体的配置和代码,你照着做也能复现。
1. 为什么要把 AI Agent 搬到 iPhone 和 iPad 上
1.1 移动端 Agent 和桌面端 Agent 的本质差异
很多人会问,桌面端跑 Agent 不香吗?为什么要自找麻烦在手机上折腾。我实际用下来发现,移动端 Agent 和桌面端 Agent 完全是两种使用场景。
桌面端的 Agent 更适合处理长周期、重资源的任务,比如批量整理文件、跑数据分析、操作后台系统。但有一个天然问题:它只能坐在工位上等你。而移动端 Agent 的价值在于“随身携带 + 感知环境”:
- 手机上的日历、提醒、位置、照片、通讯录,就是天然的外部记忆库;
- 随手拍一张照片、收到一条验证码、扫一个二维码,这些即时输入只有移动设备才能提供;
- iPad 配合台前调度(Stage Manager)可以同时开多个应用,Agent 可以成为一个常驻的“小助手面板”,和笔记、浏览器并排工作。
我最终确定的定位是:把 Agent 作为移动端的“自动化和决策引擎”。它不追求替代云端的大模型分析能力,而是负责把用户模糊的意图转成系统能执行的操作。
1.2 形态选择:原生 App、快捷指令还是 PWA
我一开始尝试过用苹果官方的 Shortcuts(快捷指令)去串 Agent 流程。说实话,捷径在“固定流程自动化”场景下非常好用,比如“每天早上下载 PDF 并发送到邮箱”,但它的短板也很明显:分支逻辑太弱,无法承载 Agent 的“动态规划”能力。Agent 最核心的能力是每一步都根据当前状态决策下一步,用 Shortcuts 写这种逻辑,复杂度会爆炸。
后来我又试了 PWA(渐进式 Web App)方案,把 Agent 的对话界面做成一个可离线缓存的网页,然后通过“添加到主屏幕”伪装成 App。这个方案跑起来很快,但卡在了一个关键点上:PWA 无法深度调用系统能力。我希望 Agent 能直接帮用户创建日历事件、发送系统通知、读写剪贴板,PWA 在这方面限制太多。
最终我选择了原生 App + 云端 Agent 服务的混合方案。原生 App 负责系统能力接入,云端负责大模型推理和任务规划。这样系统集成度和灵活性都保住了,而且后续扩展新工具时只需要在云端增加注册项,客户端不用频繁发版。
1.3 为什么把核心计算放在远端而不是本机
这里有一个很现实的问题:iPhone 和 iPad 到底能不能本地跑大模型。理论上,配备 M1 及以上芯片的 iPad 还行,iPhone 则比较吃力——即使是最新的 A 系列芯片,在内存带宽和散热上也受限。我的实测经验是,用量化后的 3B 到 7B 模型做简单的文本分类、摘要还能用,但要做稳定的多步 Agent 推理,效果和延迟都不太理想。
所以在架构上我做了分层:
- 重推理任务(任务规划、复杂决策、长文本理解)走云端大模型 API;
- 轻量任务(关键词识别、格式判断、短路拦截)在端侧用小模型做预处理。
这个方案的另一个好处是:云端 Agent 服务本身是跨平台的。今天的客户端是 iOS,明天如果要做 macOS、Android,甚至微信小程序,只要复用同一套 Agent 服务即可,客户端的成本能压到很低。
2. AI Agent 的组成结构,以及移动端怎么裁剪
2.1 四个核心模块:模型、规划、工具、记忆
很多人分不清楚 LLM 和 AI Agent 的区别,简单说:LLM 是大脑,但只有大脑无法行动。一个完整的 Agent 需要以下四样东西:
- 模型(Model):负责理解与生成,是决策中枢;
- 规划(Planning):把用户目标拆解成一系列可执行的步骤;
- 工具(Tools):Agent 可以调用外部能力,比如搜索、发通知、读写日历;
- 记忆(Memory):短期记忆负责当前任务的上下文,长期记忆负责跨会话的用户偏好。
在移动端实现时,这四个模块都要做“减法”。桌面端可以把所有工具的描述、所有历史消息全部塞进上下文,但移动端受 token 成本和响应时间限制,必须做轻量化。
我的做法是:在云端 Agent 服务里维护一个工具注册中心,每个工具只保留名称、描述、参数结构三个字段。模型决策时只看到这些元信息,真正执行时才去调用具体函数。移动端不需要了解这些细节,它只负责把用户的输入传给服务端,然后拿到一个“需要执行系统操作”的指令再落地执行。
2.2 移动端工具调用的特殊姿势:App Intents 与 URL Scheme
桌面端 Agent 调工具通常是直接调 Python 函数或命令行,但在 iOS 上,Agent 要操作系统功能,必须走苹果允许的途径。我试了三种方式,最终方案是混合使用:
App Intents:这是苹果官方推荐的方式,可以把 App 的功能暴露给 Siri、Shortcuts 和 Widget。Agent 可以在服务端生成意图指令,客户端接收到指令后调用 AppIntent,比如创建提醒、发送消息、查询照片。这种方式最干净,App Store 审核也认可。
URL Scheme:App 内部注册的协议,比如
myagent://do?action=xxx。这种方式适合 App 内部跳转和深浅链接,操作 Direct 但不够系统化,不能访问系统级数据。快捷指令回跳:如果 Agent 在服务端判断某个操作需要复杂系统权限(比如关闭 WiFi、切换蜂窝数据),客户端可以通过
UIApplication.open打开对应快捷指令 URL。当然,这类操作通常需要用户二次确认,不能全自动。
在云端 Agent 服务里,我给工具调用加了一个层级字段:local表示可以在云端直接完成(比如查天气、计算),ios表示需要客户端执行系统操作。模型在规划时会把ios类型的工具输出为一个标准 Json 指令,客户端收到后解析并执行。
2.3 记忆的落地:端侧向量库加服务端摘要
记忆是 Agent 很容易被忽略但必须设计好的模块。我见过很多 Agent Demo,聊了三轮就把自己是谁都忘了,就是因为没有记忆管理。
我的方案是“双轨制”:
服务端负责长期记忆:每次对话结束后,用大模型生成一个结构化摘要,存进数据库,同时生成向量索引。下次对话开始前,根据用户输入做向量检索,把最相关的历史记忆注入提示词。
端侧负责短期记忆:客户端的本地数据库里保留最近 N 轮对话增量,用于 UI 展示和断电恢复;另外把用户的关键设置(如常用时区、默认提醒时间)存在
UserDefaults,Agent 可以随时读取。
具体到实操,端侧我用的是一个轻量级 Swift 数据层,存 JSON 序列化的对话记录;服务端用 SQLite 加上向量扩展。可能有人问为什么不用 iOS 自带的 Core Data,我的经验是:在 Agent 场景下,读写的是 JSON 文档和向量数组,SQLite 直连反而少一层映射,调试也直观。
3. 实操:把一个完整 AI Agent 跑在 iPhone 和 iPad 上
3.1 第一步:搭一个可调用的 Agent 服务
我用的技术栈是 Python FastAPI + ReAct 模式 + 一个统一工具调度器。核心结构分三层:API 接入层、Agent 推理循环、工具执行层。
下面是简化版的服务端代码框架,去掉了一些细节但保留了核心链路:
from fastapi import FastAPI, HTTPException from pydantic import BaseModel from typing import Optional from agent_core import AgentLoop app = FastAPI() class AgentRequest(BaseModel): user_input: str session_id: str = "default" stream: bool = False extra: Optional[dict] = None class ToolCallRequest(BaseModel): session_id: str tool_id: str args: dict @app.post("/agent") async def handle_agent(req: AgentRequest): loop = AgentLoop(session_id=req.session_id) result = await loop.run(req.user_input) return {"session_id": req.session_id, "reply": result} @app.post("/tools/result") async def handle_tool_result(req: ToolCallRequest): # iOS 客户端执行完本地操作后,将结果回传 loop = AgentLoop(session_id=req.session_id) updated = await loop.handle_tool_result(req.tool_id, req.args) return {"status": "ok", "reply": updated} if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)Agent 循环是整个服务的核心,我参考了 ReAct 模式,但做了简化。每次收到用户输入后:
- 先把当前输入、最近对话摘要、命中记忆合并,生成
prompt_context; - 让模型输出“思考过程 + 行动指令”;
- 解析指令,若是工具调用则路由到工具执行器;
- 若工具需要客户端执行,则返回一个
pending_tool_call给 iOS 客户端; - 客户端执行完毕回传结果,循环继续;
- 直到模型输出完成信号。
还有一个很关键的细节:Agent 必须能区分“回复文本”和“指令 Json”。我测试过很多种提示词策略,最后采用了双重标记:要求模型在需要调用工具时,只输出一个带特定标记的 JSON 块,如```agent_action,服务端用正则提取。这样比让模型自由输出稳定得多。
3.2 第二步:iOS 端接入层(SwiftUI)
iOS 客户端用 SwiftUI 编写,核心就一个观点:客户端不需要承担任何推理逻辑,只做三件事:收集输入、展示状态、执行本地工具。
Swift 网络层代码很简单:
import Foundation struct AgentService { let baseURL = URL(string: "https://your-server.example.com")! func send(userInput: String, sessionID: String) async throws -> AgentResponse { var request = URLRequest(url: baseURL.appendingPathComponent("/agent")) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "user_input": userInput, "session_id": sessionID, "stream": false ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, _) = try await URLSession.shared.data(for: request) return try JSONDecoder().decode(AgentResponse.self, from: data) } }发送后我会等服务端返回,可能有三种结果:
- 直接返回文本:正常展示;
- 返回一个或多个
local工具调用:客户端直接执行工具,然后调用/tools/result回传; - 返回
pending_tool_call:说明需要用户确认或需要打开系统设置,走 UI 弹窗或跳转。
这里我强烈建议在客户端加一个“工具执行确认开关”。Agent 在执行高副作用操作(比如发送消息、删除照片、发送邮件)前,必须弹出确认框。不是所有操作都需要确认,我会在服务端工具注册表里配置require_confirm属性,任务级别判断。这个设计不仅是为了安全,也是为了让用户保持掌控感,减少对自动化的不信任。
3.3 第三步:把系统能力变成 Agent 工具
这是整个项目里最“移动端”味的部分。我的做法是:预定义一批 Swift 函数作为工具,通过 App Intents 框架暴露,然后在服务端注册对应的工具描述。
举个例子,让 Agent 能创建提醒:
import AppIntents import EventKit struct CreateReminderIntent: AppIntent { static var title: LocalizedStringResource = "创建提醒" static var description = IntentDescription("在当前列表创建一个新提醒事项") @Parameter(title: "内容") var content: String @Parameter(title: "时间", description: "提醒日期和时间,格式 ISO8601") var dueDate: Date? func perform() async throws -> some IntentResult { let store = EKEventStore() let reminder = EKReminder(eventStore: store) reminder.title = content reminder.calendar = store.defaultCalendarForNewReminders() if let dueDate { reminder.dueDateComponents = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute], from: dueDate ) } try store.save(reminder, commit: true) return .result() } }在服务端工具注册表中,我对应配置如下元信息:
| 字段 | 内容 |
|---|---|
| name | create_reminder |
| description | 在当前列表创建提醒事项,使用时将时间转为 ISO8601 |
| parameters | content(字符串,必填)、due_date(字符串,可选) |
| exec_target | ios |
| require_confirm | 仅在删除或发送类操作开启 |
这样,当用户在 iPhone 上输入“明天早上九点提醒我开周会”,云端 Agent 会规划出一次create_reminder调用,iOS 客户端收到后唤起 AppIntent 真正写入系统日历。整个过程用户看到的是:发了一句指令 -> Agent 思考了几秒 -> 系统弹出操作确认 -> 提醒创建成功。
类似的系统工具我还做了一批:读取剪贴板、把指定文本写入剪贴板、创建日历事件、查询当天日程、发送本地通知、获取当前位置(需要授权)、控制后台播放。每个工具的接入成本大约半小时到一小时,真正费时间的是调优工具描述,让模型不误调用。
3.4 第四步:iPad 端的差异化优化
iPad 和 iPhone 在移植上有个特别重要的差异:多任务和键盘。但真正动手之后你会发现,工作量和收益完全不成正比。
首先是台前调度(Stage Manager)。我把 Agent 客户端做成了支持多列的宽屏布局,在 iPad 上运行的时候,左侧是对话历史,右侧是 Agent 的状态可视化面板(当前工具调用、模型思考过程、记忆命中情况)。这样可以配合台前调度,实现一边用浏览器查资料,一边让 Agent 在旁边执行任务,实时观察它每一步在做什么。这种体验很像在电脑上跑 Agent 时的“看它在一步步操作”的感觉,但在 iPad 上用触控和缩放来操作,效率意外地高。
其次是多窗口。iOS 16 之后支持打开的多个窗口实例,我做了多 Session 支持,可以一边跟 Agent 讨论工作,一边让另一个 Session 处理个人日程,互不干扰。
最后是热重连。iPad 经常会被合上盖子或切换 App,后台进程容易被系统挂起。我做了自动断线重连和本地状态恢复机制,重新打开 App 时会自动恢复上次会话上下文,并且把未完成的工具调用标记成“已取消”,避免 Agent 一直等一个永远不会回传的结果。
4. 常见问题与排查技巧实录
4.1 大模型响应慢,客户端直接超时
这个是我踩过最大的一个坑。云端 Agent 处理一个简单问题的耗时通常在 3 到 8 秒,如果中间有多次工具调用,可能要 15 秒以上。而 iOS 系统对网络请求的默认超时时间往往不够,我最初设置 10 秒超时,几乎每三次就有一次超时。
解决方案有三层,我全部用上了:
- 第一层:客户端把超时时间放宽到 30 秒,并设置“流式接收”模式,收到一个字符就渲染一个字符,避免大白屏等待;
- 第二层:服务端做了任务状态持久化,客户端轮询任务状态而不是死等一个 HTTP 响应;
- 第三层:对长时间运行的任务,服务端先返回
task_id,Agent 完成后再通过本地通知推送结果。
实践中第三层最稳。用户发送指令后立即收到“任务已受理”的反馈,Agent 在后台跑,完成时再通知。这更符合移动设备的使用习惯。
4.2 工具调用不稳定的排查思路
模型调用工具时偶尔会输出不存在的参数,甚至凭空捏造工具名。我总结出四个排查步骤:
- 看服务端日志,确认模型原始输出,而不是解析后的结果。有时候是解析代码的正则写错了,反而怪罪模型;
- 检查工具描述是否过于冗长。工具描述太长会挤占上下文,我控制在 100 字以内;
- 检查参数类型。模型有时会把
int参数传成字符串,服务端要做严格类型转换,不能直接透传; - 增加一次“工具参数校验”。在真正执行工具前,用一个小模型或规则集校验参数是否完整,缺失时自动让模型重新生成。
4.3 本地小模型到底能不能跑
我在支持 M 系列芯片的 iPad 上试过本地跑量化模型,用 Ollama 加载 3B 到 8B 的量化版本。结论是:做文本理解没有任何问题,但做稳定的 Agent 任务规划还不够。原因有两个:
- 模型遵循输出格式的稳定性不够,经常不按约定的 Json 格式返回;
- 上下文窗口有限,加上工具描述和记忆内容后,留给对话的空间就不够了。
如果只是想做一个“低成本的离线预处理层”,本地模型完全可以,但想完全离线跑通一个多步骤 Agent,目前体验还不太行。我的建议是:把端侧能力限制在“单步任务”如回复模板生成、内容分类,不要让它承担完整规划。
4.4 后台权限问题
iOS 有严格的后台机制,App 进入后台后,网络请求随时可能被挂起。所以在真实使用中,我几乎不会让 Agent 任务在 App 后台长时间运行。做法是:把长任务放到服务端,客户端只负责发起和接收结果。如果用户切到了其他 App,服务端任务照常执行,等完成后通过本地推送通知告知。
这里有个小细节:App 被用户手动强制退出后,本地通知也会失效。如果你要做类似场景,记得在 App 设计上提醒用户“不要手动杀后台”,或者用后台推送唤醒机制重新发起轮询。
写在最后的一点经验
跑通这套系统之后,我对移动端 Agent 最大的感受是:技术上最难的不是模型,也不是 SwiftUI 界面,而是把“工具边界”设计合理。桌面端 Agent 可以随便执行 shell 命令、读写文件,但在 iOS 上,你必须在系统允许的范围内,把每个操作拆成明确的、用户可控的工具。这个限制反而逼着你去思考一个问题:哪些操作是 Agent 真正应该有权限做的。
我自己后续打算把这个项目的工具库再扩展一下,把健康和运动数据的读取也加进去,让 Agent 能根据近期的运动状态调整提醒和建议。如果你也在做类似的事情,我想听的你踩坑记录,尤其是工具调用和权限设计这两块,值得一起交流。