资讯动态

基于SwiftUI的移动端智能体框架:Agent与Skill调度实践

发布时间:2026/10/14 9:03:32 来源:尧图企业网站定制
最近我把大半精力砸在了一个 SwiftUI 项目上。项目代号是 SwiftUI-Agent-Skill拆开看并不复杂SwiftUI 负责界面Agent 负责决策Skill 负责干活。把三者拼起来就是一个能自主判断、按需调用本地工具、并通过 SwiftUI 实时渲染执行过程的 iOS/macOS 端智能体应用框架。这个项目不是传统意义上的“聊天机器人外壳”而是一个面向智能体的客户端底座。你给 Agent 一个目标它能自己拆解步骤、选择合适的技能执行、把结果反馈给模型、再生成最终答复。比如“查一下最近三天天气如果周六不下雨就提醒我安排户外活动”这种需要同时调度天气查询、日历提醒、规则判断的任务服务端模型做不到因为本机日历和定位权限不归它管。技能必须长在客户端状态必须由客户端编排。这篇文章我会把这套 SwiftUI Agent 框架的设计思路、Skill 调度机制、核心代码实现、以及我在实际开发里踩过的坑全部摊开讲。适合正在做 iOS 端 AI 应用、想把大模型能力和系统能力真正落地的开发者参考。项目不算大但里面有几个决策回头看确实决定了整体体验的上限。1. 整体设计逻辑为什么把智能体搬进 iOS 客户端1.1 移动端智能体的价值与边界大多数 Agent 项目跑在服务端模型在云端工具调用在云端客户端只是个展示壳。这种架构处理网页检索、数据库查询这类服务端能力没有问题但一旦涉及本地数据就立刻碰壁。举个例子。用户对助手说“我明天上午十点的会能不能帮我提前到九点半顺便看看那个时间段谁空闲。”这句话里至少需要三个能力读取本机日历、读写日程事件、读取通讯录并比对成员行程。服务端模型就算能理解意图也没有权限接触这些数据。即便用户愿意把日历同步到云端也存在隐私泄露风险。更别提定位、传感器、本地文件这些天然属于端侧的能力硬搬到服务端既不安全也不现实。所以移动端 Agent 的核心价值是把“推理在云端行动在本地”这条链路真正打通。Agent 引擎在客户端负责三件事理解用户输入、决定调用哪个技能、把技能结果送回模型生成回复。大模型推理依然走服务端接口但状态编排、工具调度、数据访问全在端侧完成。这个分工不是技术选型的偏好问题而是隐私边界和能力边界共同决定的必然结果。我在这套框架里把 Agent 定位成“脑袋在云端手脚在本地”的协作体。脑袋负责理解、规划、生成手脚负责执行具体操作并反馈结果。两者通过结构化的消息协议沟通谁都不越界。1.2 三层架构与 Agent-Skill 的模块划分第一版我犯过一个典型错误把 Agent 引擎、技能执行、UI 状态全部塞进一个类里。结果当然是灾难一个技能卡住整个界面跟着转圈想单独测试日历技能还得先启动整个聊天页。重构之后我按三层切分效果立竿见影。UI 表现层只负责渲染状态不包含任何业务逻辑。聊天消息列表、输入栏、技能执行状态卡片、工具调用详情卡片这些都是无状态的视图组件。它们接收 Agent 引擎发布的状态刷新界面。智能体核心层是唯一的“大脑”。AgentEngine 管理会话状态机、技能注册表、上下文历史、流式消息通道。它不关心具体技能怎么实现只关心如何调度。工具能力层是“手脚”。每个技能一个独立单元比如天气技能、日历技能、提醒技能、搜索技能。技能内部自己处理参数校验、数据源访问、错误兜底对外只暴露统一接口。模块划分对照表层次核心职责典型组件依赖关系UI 表现层渲染状态、收集用户输入ChatView、ToolCallCard、StatusIndicator只依赖核心层暴露的状态接口智能体核心层状态机、技能注册、上下文管理AgentEngine、SkillRegistry、ContextManager依赖技能协议不依赖具体技能工具能力层执行具体动作、访问系统能力WeatherSkill、CalendarSkill、ReminderSkill只依赖协议定义不感知 UI这个分层说起来平淡但意义很大。技能能热插拔我可以随时往注册表里塞一个新技能不用改动 Agent 引擎一行代码。引擎也能单独测试不启动 UI 就能跑完整调度链路。1.3 SwiftUI 选型状态驱动是 Agent UI 的最优解做 Agent 客户端UI 框架选型其实没有太多悬念。Agent 的执行过程天然是一个状态机空闲、思考中、等待工具返回、生成回复中、出错。这套状态机几乎是为 SwiftUI 的声明式范式量身定做的。UIKit 时代写这样的界面你得手动管理回调。技能执行结果回来你要在主线程更新 label、切换按钮状态、滚动列表、隐藏加载动画哪一个环节忘了就是界面错乱。SwiftUI 的写法完全不同视图只是状态的函数。我把 Agent 状态定义成枚举视图根据枚举值自动切换展示形态。状态变了界面跟着变不存在“忘记更新某个控件”的问题。Swift 并发也帮了大忙。async/await 让异步技能调用的代码读起来像同步逻辑Actor 隔离避免状态竞争。配合 iOS 17 的 Observable 宏Agent 引擎可以直接作为可观察对象驱动视图刷新省掉大量 Publisher 管道代码。多端复用是我选 SwiftUI 的另一个理由。同一套代码可以跑 iPhone、iPad、Mac甚至未来上 visionOS。Agent 这种产品形态天然适合多端一致体验。用户在手机上发起任务在 Mac 上继续查看进度如果每个端写一套 UI工作量会翻倍。但这不意味着 SwiftUI 全无痛点。复杂的富文本流式渲染、原生性能瓶颈、某些系统控件不可定制我在后面排查实录部分会展开讲。选型的时候只看优势落地的时候才知道坑在哪。2. 核心机制技能怎么定义、注册、调度2.1 Skill 协议把能力拆成可插拔模块整个框架的地基是一份极简协议。定义一个技能需要四个要素唯一标识、展示名称、能力描述、参数声明。能力描述写给模型看参数声明告诉模型“这个技能需要哪些参数、参数什么格式”。protocol AgentSkill { /// 技能唯一标识用于注册表索引 var identifier: String { get } /// 展示名用于 UI 显示 var displayName: String { get } /// 能力描述会注入系统提示词 var description: String { get } /// 参数声明JSON Schema 格式供模型生成工具调用 var parametersSchema: String { get } /// 执行入口 func execute(arguments: [String: Any]) async throws - SkillOutput } struct SkillOutput: Sendable { /// 结构化结果数据 var data: String /// 附加 MIME 数据图片/文件等 var attachments: [Attachment] [] /// 本次执行消耗备注如 token 数、耗时 var metrics: SkillMetrics }这套协议最重要的特性是“薄”。我只定义了协议层面必须的信息不掺入任何业务假设。后来我见过有人把“技能是否支持流式进度”“技能是否需要用户确认”“技能 UI 类型”全部塞进协议后果是每个新技能都要被迫实现一堆用不到的字段。协议薄一点扩展性反而强。为什么用协议而不是 switch-case 分发第一是扩展性新增技能只需要新增一个结构体并注册不需要改动任何老代码。第二是可测试性每个技能都是独立类型可以单独写测试用例。第三是动态性远程下发的技能包可以反序列化后动态注册进注册表这为后续做“技能市场”留好了口子。2.2 调度链路从用户输入到工具调用Agent 引擎的调度链路是整套系统的核心管道。我的实现方式是标准 ReAct 模式的轻量版模型不是直接生成答案而是先判断“需不需要工具”需要的话生成一个结构化工具调用请求等工具结果回来再继续生成。完整链路是这样的。用户消息进入 AgentEngine引擎把系统提示词、历史上下文、可用技能描述拼装成请求发给大模型接口。模型返回结果里带了一个工具调用指令技能标识 参数 JSON。引擎拿着标识去注册表查实现拿到技能实例后把参数传进去执行。执行结果被包装成工具结果消息连同原始工具请求一起再次发给模型。模型这时已经知道工具执行发生了什么会基于真实结果生成最终回答而不是凭空编造。这条链路里的关键设计是工具请求和工具结果必须严格成对回传。如果只传结果不传请求模型会丢失上下文不明白这个结果对应的是哪一次调用。我在协议里专门定义了 ToolCallRecord包含调用 ID、技能名、参数、结果、时间戳。会话历史里保存完整记录模型翻看历史时能还原每一步。状态机是引擎的中枢。我把 Agent 状态定义成枚举enum AgentState: Equatable { case idle case thinking case awaitingTool(String) case generating case failed(String) }状态变化遵循严格次序。用户发送消息后进入 thinking模型返回工具调用后进入 awaitingTool技能执行完毕回到 thinking最后模型生成回复进入 generating结束后回到 idle。UI 完全由这个状态驱动。状态用户看到什么引擎在做什么idle普通聊天界面等待输入thinking思考动画请求模型判断意图awaitingTool技能卡片显示技能名正在执行技能generating流式打字效果接收模型返回值failed错误提示 重试按钮记录错误日志状态机设计成单向流转禁止跳步。比如 awaitingTool 状态下用户又发了一条新消息引擎会先丢弃当前技能执行结果再重新进入 thinking。这个“打断”机制不复杂但没有它用户连续输入时会得到一堆前后矛盾的回答。2.3 流式输出与上下文管理大型语言模型的生成是流式的一个 token 一个 token 往外吐。客户端不能干等全部生成完再显示那样用户体验极差。SwiftUI 里处理流式输出我推荐用 AsyncStream天然适配 Swift 并发模型。class AgentEngine { func streamResponse(session: Session, userInput: String) - AsyncStreamString { AsyncStream { continuation in let task Task { [weak self] in do { let provider try self?.makeProvider() for try await token in provider.streamCompletion(messages: session.messages) { guard !Task.isCancelled else { break } continuation.yield(token) } continuation.finish() } catch { continuation.finish(throwing: error) } } continuation.onTermination { _ in task.cancel() } } } }UI 侧用一个任务消费这个流把 token 增量追加到当前助手消息文本上。这里有个重要细节流式生成期间原始 token 流和最终完整回答要保持一致。如果中途 UI 把部分 token 显示出来了但最终消息模型里又拼接出另一个版本用户会看到文本“跳变”。我的做法是 UI 收到的每个 token 同时追加到一个可观察的缓冲区里最终提交时就以缓冲区内容为准不再重复拼接。上下文管理是 Agent 的大脑记忆。我把消息分成三种类型用户消息、助手消息、工具调用记录。工具调用记录又包含请求和结果两个部分。会话历史按时间顺序线性存储模型需要的是完整决策链路不能只存对话文本。如果一个技能返回了一长串 JSON系统把这个 JSON 原样塞进历史模型就能准确知道工具看到了什么。注意工具返回的 JSON 字符串一定要原样回填给模型不要转成 Swift 字典再序列化。序列化过程中键的顺序可能变化导致模型生成的后续调用参数对不上号。2.4 记忆与 Token 成本控制聊到上下文管理就不能回避成本。所有历史消息都被发送给模型每多一轮对话Token 消耗就增加一轮。我实测过一段 20 轮的普通对话上下文涨到近两万 Token单次请求的费用明显上升更重要的是响应延迟变长。对客户端应用来说延迟直接等于用户体验下降。我的方案是分层记忆。当前会话保持完整历史但设置了窗口上限超过 30 轮就触发一次摘要压缩。摘要不是简单砍掉老消息而是调用模型生成一段结构化的会话摘要包含用户的核心诉求、已经完成的技能调用、尚未解决的事项。摘要替换掉最老的一部分原始消息塞进系统提示词。这样模型依然知道之前发生了什么但 Token 占用大幅下降。长期记忆我单独放了一层。用户显示出明确偏好的句子比如“我最常在北京办公”“我习惯每天早上看天气预报”会被抽取出来存进本地数据库下次新会话时作为全局偏好注入。这套机制非常轻量不依赖任何云端存储本地数据库即可。Token 成本控制还要注意技能返回的负载。一次复杂的日历查询可能返回上千字的 JSON里面有大量模型不关心的字段。我的经验是可预期的大数据量技能在返回结果前先做一次字段裁剪。只保留模型可能需要的信息其余全部丢弃。这能节省 40% 左右的上下文开销。3. 实操演示从零跑通一个带技能调用的 SwiftUI Agent3.1 项目准备与目录结构动手之前先把环境说清楚。我用的 Xcode 15 及以上版本iOS 17 作为最低部署目标Swift 5.9。iOS 17 的 Observable 宏是这套框架体验的关键一定要开。目录结构我建议按职责分三层而不是按文件类型分。很多项目按“Models / Views / ViewModels”组织目录但 Agent 项目里技能类型横跨模型和逻辑拆开反而难受。我的组织方式AgentSkillDemo/ ├── App/ │ └── AgentSkillDemoApp.swift ├── Core/ │ ├── AgentEngine.swift │ ├── AgentState.swift │ ├── SkillRegistry.swift │ └── Models/ │ ├── AgentMessage.swift │ ├── ToolCallRecord.swift │ └── SkillOutput.swift ├── Skills/ │ ├── WeatherSkill.swift │ ├── CalendarSkill.swift │ └── ReminderSkill.swift └── Views/ ├── ChatView.swift ├── MessageBubble.swift ├── ToolCallCard.swift └── StatusIndicatorView.swiftCore 放引擎和协议定义Skills 放具体技能实现Views 放 UI。这个结构随着技能数量增长不会乱。每个技能独立文件互不引用。3.2 实现 LLM 接入层与技能注册中心大模型接口不能跟引擎写死在一起否则换服务商就要动引擎。我抽象了一层 LLMProvider 协议引擎只依赖协议。protocol LLMProvider { var supportsToolCalling: Bool { get } func streamCompletion(messages: [ChatMessage], tools: [ToolDefinition]) - AsyncThrowingStreamString, Error func completion(messages: [ChatMessage], tools: [ToolDefinition]) async throws - ChatResponse }实际实现时就是把 messages、tools 转成服务商要求的 JSON 格式发出去解析返回流。不同的服务商返回格式差异很大有的把工具调用放在独立的字段里有的直接在文本里夹带 JSON。我的建议是工具调用尽量用服务商原生能力不要靠解析文本来猜测。解析文本约等于踩雷模型稍微改一下输出格式解析就崩。SkillRegistry 是一个技能注册中心本质是字典映射。没有花哨的机制就是用标识符做查找Observable final class SkillRegistry { private var skills: [String: any AgentSkill] [:] func register(_ skill: any AgentSkill) { skills[skill.identifier] skill } func skill(for identifier: String) - (any AgentSkill)? { skills[identifier] } func allSkills() - [any AgentSkill] { Array(skills.values) } }注册中心我用了 Observable这样 UI 可以在技能注册后自动刷新可用技能列表。比如做“技能商店”页面时动态添加技能并实时展示这个设计就很有用。3.3 写一个真实的天气类技能框架搭好技能实现反而简单。以天气技能为例一个可用的最小实现长这样。这里我用模拟数据源来演示协议的使用方式实际项目换成任意公开天气接口即可。struct WeatherSkill: AgentSkill { let identifier weather.daily_forecast let displayName 天气预报 let description 获取指定城市未来几天的天气预报。当用户询问天气、气温、降雨可能时使用此技能。 let parametersSchema { type: object, properties: { city: { type: string, description: 城市名称如 北京 }, days: { type: integer, description: 预报天数默认 3 } }, required: [city] } func execute(arguments: [String: Any]) async throws - SkillOutput { guard let city arguments[city] as? String else { throw SkillError.invalidArgument(缺少 city 参数) } let days arguments[days] as? Int ?? 3 let result try await fetchForecast(city: city, days: days) return SkillOutput( data: result.jsonString, attachments: [], metrics: SkillMetrics(duration: 0.3, tokenCost: 0) ) } private func fetchForecast(city: String, days: Int) async throws - WeatherForecast { // 接入真实天气数据源这里用模拟数据 WeatherForecast(city: city, days: days) } }注意参数校验放在 execute 的第一步。模型偶尔会生成缺失参数的调用与其让技能内部崩溃不如先抛出一个描述清晰的错误。错误信息会被引擎捕获并回传给模型模型看到错误后有机会重新生成参数。这就是 2.2 里说的自愈链路。3.4 拼一个能用的聊天界面界面部分我保持朴素但功能完整。ChatView 由三块拼成消息列表、底部输入栏、技能状态卡片区。struct ChatView: View { Bindable var engine: AgentEngine var body: some View { VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(engine.visibleMessages) { message in MessageBubble(message: message) .id(message.id) } if let activeSkill engine.activeSkill { ToolCallCard(skill: activeSkill) .id(skill-card) } } .padding() } .onChange(of: engine.visibleMessages.count) { withAnimation { proxy.scrollTo(skill-card, anchor: .bottom) } } } InputBar( text: $engine.inputText, isBusy: engine.state ! .idle, onSubmit: { engine.send(input: engine.inputText) } ) } } }ToolCallCard 是这套 UI 里最出效果的组件。它实时显示当前正在执行哪个技能、执行了多久、当前状态。用户看到的不只是“AI 正在输入”而是一张清晰的任务卡“正在调用 天气预报 → 查询北京 3 天预报”。产品层面这个组件极大提升了用户对 Agent 的信任感。技术上它只是把 awaitingTool 状态的技能信息渲染出来逻辑很薄。一个关于性能的小技巧流式文本别直接把每一段 token 都 append 给 Text 视图。高频刷新 Text 会带来可见的卡顿。我的做法是维护一个 Token 缓冲区每 50ms 把缓冲区里的内容批量化渲染一次。这个刷新频率人眼感知不到延迟GPU 压力却少了一个量级。4. 常见的坑与排查实录4.1 流式文本刷新卡顿症状模型生成的第一个 token 开始界面的流式文本区块明显掉帧iPhone 机身发热。排查过程我先以为是字体渲染问题换成了系统字体无效。又怀疑是 List 复用问题改用 LazyVStack还是卡。最后定位到元凶每个 token 追加到消息模型后Observable 会让整条消息视图全部重新求值同时 ScrollView 的可见范围在滚动双重压力叠加。解决Token 批量化刷新50ms 一个批次。每批只更新文本尾部追加不触发全列表重排。另外把流式文本的视图单独拆出来用一个专用的局部 State 管理缓冲区避免扩散到整个消息 Cell。struct StreamingTextView: View { State private var displayedText: String private let buffer TokenBuffer() var body: some View { Text(displayedText) .task { for await batch in buffer.batches() { displayedText batch } } } }心得SwiftUI 的 Text 是一个很重的视图不要拿它当纯文本容器高频 append。把它当作不可变帧每一帧只赋值一次完整文本性能最稳。4.2 并发导致的状态覆盖症状用户快速连发两条消息第一条的技能结果晚于第二条的技能结果返回界面显示的是第二条的输入底部却挂着第一条的技能卡片。对话记录张冠李戴。排查过程AgentEngine 不是线程安全的。两条消息各自开启了 Task技能执行结果回来时各自修改同一个状态字段后写覆盖先写没有顺序保证。解决AgentEngine 加 MainActor 隔离。所有状态变更收口到主 actor技能执行结果通过 AsyncStream 回传。发送新消息时给这次会话生成递增的 taskId状态更新前比较 taskId 是否还属于当前会话。追加上下文时先锁再写。真正根治是在引擎里做了串行化。发送消息的入口只有 send(input:) 一个内部通过 AsyncStream 串行消费技能执行结果永远不会出现并发写。4.3 上下文失控与 Token 费用飙升症状对话到 20 轮左右每次请求的响应时间从 1 秒涨到 3 秒单次费用涨了十倍。打开请求日志发现上下文里塞了 6 万 Token其中一半是技能返回的完整 JSON。排查过程技能执行的原始输出被原样追加到历史消息里。日历技能一次返回 2000 Token 的 JSON对话多轮拆下来历史里积累了海量模型并不真正关心的字段。系统提示词里技能描述也在膨胀技能数量越多描述越长。解决三层手段。第一技能返回结果裁剪只保留字段子集。第二上下文滑窗加摘要压缩超出 30 轮的消息改为生成摘要原始消息移出上下文。第三技能描述做量化每个技能只用两句话概述能力把完整说明移到技能自身文档里系统提示词瘦身。4.4 工具执行失败如何自愈症状模型生成的技能参数不符合预期比如城市名写成英文、日期格式错误。技能 execute 抛异常引擎直接终止本次回复用户看到一条错误提示体验很冷。排查过程我把错误透传给了 UI没有反馈给模型等于切断了自愈路径。正确做法是把错误信息包装成工具结果返回给模型。做法是修改调度链路技能执行出错时不直接抛给 UI而是构造一条 ToolCallResult 消息内容为结构化错误描述发送给模型。模型看到错误后会重新生成一个修正后的参数调用。重试最多允许两次超过后还是失败才向用户展示错误卡片。失败类型典型原因处理策略参数缺失模型生成时漏字段参数校验先行给出缺失字段提示参数格式错误日期/枚举值不合法返回错误模型根据描述修正参数网络超时技能数据源不可用超时熔断提示用户稍后重试权限不足本地权限被拒绝引导用户打开系统设置这个自愈链路让技能调用的成功率从 60% 提升到 85% 以上。代价是重试时多一次模型请求整体延迟增加但换来的是“任务最终完成”值得。5. 复盘与后续扩展5.1 设计上最值得保住的三件事第一协议薄。Skill 协议只定义“最小可行接口”没有塞入任何业务字段。技能实现非常轻新技能从零写完到注册半小时以内。如果当初贪多把权限、UI、流式进度全部塞进协议这套框架的扩展性会大打折扣。第二UI 是 Agent 状态的投影。这句话不是空话而是设计原则。UI 不独立维护执行状态引擎状态一变视图自动跟着变。聊天界面、技能卡片、输入栏的禁用逻辑全部由 AgentState 驱动不存在多个状态源互相打架的问题。第三日志埋点从一开始就做。每次技能调用记录耗时、成功失败、参数摘要、Token 消耗。后期做优化、排查故障、估算费用时这些数据直接可用。没有埋点的 Agent 框架出了问题只能靠猜。5.2 可以继续做的几个方向第一个方向是远程技能下发。客户端内置技能是一个固定集合。想要扩展可以把技能定义 JSON 化、上传到自己的服务端客户端按需拉取并动态注册。配合代码签名可以做成一个真正意义上的技能市场。协议薄这个特点让动态注册变得非常顺。第二个方向是多模态技能。天气、日历是标准文本技能图片理解、语音转写、拍照识别这类技能需要处理非文本输入输出。我的 SkillOutput 已经预留了 attachments 字段可以承载图片和文件但引擎的消息协议里多模态内容的对齐还要进一步设计。第三个方向是本地模型兜底。云端大模型有网络依赖弱网环境体验不佳。给引擎加一个本地小模型用于意图判断当网络不可用或超时至少能识别用户是想查天气还是加提醒缓存到本地等网络恢复再执行完整技能链路。这个能力加入后Agent 的工具性会强一个档次。如果让我重做一遍第一件事就是把协议定义得更薄不在协议入口加任何业务假设。第二件事是别急着做“全自动 Agent”先把一个技能链路打磨到极致再扩展多技能协同。多技能协同的复杂度和单技能调通完全不在一个量级数据一致性和状态冲突会消耗大量精力。把一个核心场景做穿用户能感知到价值后面扩展才有底气。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑