资讯动态

SwiftUI-Agent-Skill:基于 SwiftUI 的 Agent 技能系统架构设计

发布时间:2026/10/14 8:42:07 来源:尧图企业网站定制
最近我把一个折腾了两三周的 SwiftUI-Agent-Skill 项目收尾了。简单说这是一个基于 SwiftUI 的 Agent 应用骨架核心思路是把“大模型对话”和“具体工具能力”拆成两层Agent 负责理解用户意图、决定该调用哪个技能Skill 则是可以被动态注册、被模型按需触发的一组可执行单元。这个项目做完之后我最大的感受是在苹果生态里搭 Agent 应用最麻烦的其实不是聊天界面也不是模型接入而是“技能”这套体系怎么设计才能既灵活又可控、既方便扩展又不会失控。这篇文章把我在这个项目里的架构设计、协议定义、SwiftUI 接入方式、完整 Demo 代码和踩坑记录都整理出来适合正在做 iOS/macOS 端 AI 应用的开发者也适合想搞懂 Agent 技能机制怎么落地的同学。1. 先搞明白Agent 应用里“技能”到底承担什么角色1.1 从一次“查天气”的对话拆起把场景拉回到一个最普通的用户请求用户打开 App输入“北京明天会下雨吗”。传统做法写死逻辑分支代码里怼一个 switch 或者 if-else命中关键词“天气”就去调天气接口。但用户表述千变万化“北京明天有没有雨”“帮我看看明儿北京天气”“后天降温吗”每换一种说法你就得改规则规则多到一定量级基本就维护不动了。换成 Agent 方案之后流程完全变了App 先把一大段“技能说明书”交给模型模型读完用户的话自己判断该调用哪个技能并抽取出结构化参数比如城市“北京”、日期“明天”。App 拿到这个指令后再去执行对应的技能代码最后把结果喂回模型生成回复。这里的“技能说明书”就是本项目里 Skill 的核心载体。所以简单定义一下技能Skill就是一组能被模型根据用户意图动态触发的、有明确输入输出约定的功能单元。它不再由固定的 if-else 决定何时运行而是由模型在对话上下文中做决策。这个转变是整个 Agent 应用和传统指令式应用最本质的区别。1.2 架构分层对话、决策、执行三件套这个项目我把整体拆成三层各司其职对话层SwiftUI 负责的聊天界面展示用户消息、模型回复、技能执行状态。这一层只管“显示”不掺任何业务决策。决策层Agent 服务负责把用户输入连同技能清单发给模型解析模型返回的“工具调用”意图。这一层是大脑但不亲手做事。执行层Skill 集合每个技能只知道自己怎么完成一件事比如查天气、设倒计时、读写备忘录。它们被注册中心统一管理按 ID 被决策层调用。这三层如果混在一起写项目前期很爽因为代码少一旦技能数量超过三五个你就会发现聊天逻辑里全是业务判断改一个技能就牵动整个文件。分层之后新增一个技能只需要“写一个 Skill 实现 注册”其余代码零改动这个收益在技能数量增长之后会越来越大。1.3 为什么把 UI 层和技能层彻底解耦有人可能觉得SwiftUI 项目里 UI 和逻辑天然就该耦合直接视图里调 API 完事。但这种做法在 Agent 场景里有三个致命问题。第一技能执行往往是异步且耗时的如果直接在 View 里处理状态管理会变成一团乱麻视图刷新和任务取消都难控制。第二技能可能被多处触发聊天输入、快捷指令、工具栏按钮你总不能让每个入口都复制一份调用代码。第三测试困难。UI 里的逻辑没法脱离界面单测而技能协议一旦抽出来每个技能都可以独立写单元测试mock 模型返回也很方便。所以我在项目里用 Store 作为中间层SwiftUI 视图只和 Store 交互Store 再调度 Agent 服务和技能注册中心。2. 技能系统的地基Skill 协议与注册中心2.1 一个协议把技能“标准化”项目里所有技能都遵循同一个协议。这是我反复调整之后定下来的版本import Foundation protocol Skill: Identifiable where ID String { var id: String { get } var name: String { get } var desc: String { get } var parameterSchema: String { get } func execute(arguments: [String: Any]) async throws - String }每个字段都有它存在的理由id技能的唯一标识注册和调用的索引。协议里直接限定ID String省得后面到处写any Skill.ID。name给人看的技能名称也方便模型在决策时快速识别。desc技能的功能描述这行字是写给模型看的。写得好不好直接决定模型会不会在正确时机触发它后面单独展开。parameterSchema参数约束用 JSON Schema 字符串描述。模型依据它生成结构化参数。execute真正干活的入口入参是模型解析出来的参数字典返回执行结果的文本描述。async throws这个签名我特意保留了。技能内部可能涉及网络请求、文件读写、硬件调用全部异步化是必须的throws则把失败处理统一交给上层避免每个技能自己吞错误。2.2 参数描述用 JSON Schema模型才能看懂模型不读 Swift 类型它只认结构化的、带语义的描述。所以每个技能的参数约束我直接用 JSON Schema 字符串。举一个天气技能的例子{ type: object, properties: { city: { type: string, description: 城市名称例如北京、上海、广州 }, days: { type: integer, description: 查询未来几天默认 1最大 7 } }, required: [city] }这里有两个容易被忽略的细节。第一description字段比type字段更重要。模型是通过描述来理解参数含义的如果你只写city: { type: string }模型大概率会传缩写、传拼音、甚至传空值。你把“北京、上海、广州”这种示例写进去模型抽参数的成功率会明显提升。我实测下来带示例和不带示例参数抽取成功率差了大概两成。第二required必须显式声明。缺了它模型可能“好心”地把必要参数省略掉执行层就会因为拿不到city而抛错。宁可严格声明再在执行层做兜底也不要让模型自由发挥。执行层拿到的是[String: Any]字典。这个字典怎么从模型返回的 JSON 字符串转换我在项目里写了个小工具struct ToolCall { let skillID: String let arguments: [String: Any] let plainReply: String? } extension ToolCall { init(skillID: String, argumentsJSON: String, plainReply: String?) throws { self.skillID skillID self.plainReply plainReply if let data argumentsJSON.data(using: .utf8), let object try JSONSerialization.jsonObject(with: data) as? [String: Any] { self.arguments object } else { self.arguments [:] } } }用JSONSerialization而不是强类型Decodable是因为技能参数天然是动态的你不可能为每个技能定义严格的 Codable 结构字典透传反而最灵活技能内部自己决定怎么取。2.3 注册中心把硬编码 if-else 干掉有了协议还得有个地方统一管技能这就是注册中心。我写了这么个类MainActor final class SkillRegistry { private var storage: [String: any Skill] [:] func register(_ skill: any Skill) { storage[skill.id] skill } func skill(for id: String) - (any Skill)? { storage[id] } func allSkills() - [any Skill] { Array(storage.values) } var skillsManifest: String { allSkills().map { skill in 技能名称\(skill.name) 技能 ID\(skill.id) 功能说明\(skill.desc) 参数约束\(skill.parameterSchema) }.joined(separator: \n\n) } }skillsManifest这个计算属性很关键它把全部技能拼成一段“说明书”决策层直接把它塞进模型请求的上下文里。模型看到这段文字就知道你提供了哪些能力、每个能力怎么用。注册模式的另一个好处是支持条件启用。比如某个技能依赖网络权限用户没授权就不注册某个技能是付费能力会员才注册。这些都是运行时决定的用注册中心就很好控制硬编码则完全做不到。多提一句MainActor修饰注册中心是因为 App 启动时注册动作发生在主线程后面技能查询也在主线程居多这个约束能避免不少数据竞争问题。3. SwiftUI 界面层状态、异步和技能联动3.1 状态载体选型ObservableObject 还是 ObservableSwiftUI 里管全局状态老牌方案是ObservableObjectPublished新的是 iOS 17 的Observable宏。这个项目我选了老牌方案主要是两个考虑一是Observable在预览和某些组合场景下还有兼容性暗坑二是项目里要手动控制发布时机Published的语义更直白新人接手也容易看明白。核心状态类长这样MainActor final class AgentStore: ObservableObject { Published var messages: [ChatMessage] [] Published var isThinking false Published var activeSkillID: String? private let registry: SkillRegistry private let modelService: LLMService init(registry: SkillRegistry, modelService: LLMService) { self.registry registry self.modelService modelService } }messages是整个聊天记录的单一数据源isThinking控制输入框和按钮的交互状态activeSkillID用来在界面上标记“当前正在执行哪个技能”比如在消息气泡里展示一个技能执行中的占位提示。这三个状态基本覆盖了 Agent 交互 UI 的全部需求。3.2 消息流转从用户输入到技能执行AgentStore 的核心方法是send完整流程是用户输入 - 追加用户消息 - 调用模型请求技能决策 - 命中技能则执行并追加系统提示 - 把执行结果交还模型生成最终回复 - 追加模型回复。func send(_ text: String) async { let userMessage ChatMessage(role: .user, text: text) messages.append(userMessage) isThinking true defer { isThinking false } do { let toolCall try await modelService.requestToolCall( userText: text, skills: registry.allSkills() ) if let toolCall, let skillID toolCall.skillID { guard let skill registry.skill(for: skillID) else { throw AgentError.skillNotFound(skillID) } activeSkillID skill.id messages.append(ChatMessage(role: .system, text: 正在调用技能\(skill.name))) let result try await skill.execute(arguments: toolCall.arguments) let reply try await modelService.generateReply( toolResult: result, userText: text ) messages.append(ChatMessage(role: .assistant, text: reply)) } else { let text toolCall?.plainReply ?? 我没理解你的意思。 messages.append(ChatMessage(role: .assistant, text: text)) } } catch { messages.append(ChatMessage(role: .system, text: 出错了\(error.localizedDescription))) } activeSkillID nil }这里有个容易出错的点defer只能保证isThinking复位但activeSkillID可能在guard提前 return 时卡住不清理。所以我特意不用guard的隐式 return而是把逻辑都包在if let里确保走到catch或自然结束时能统一清理状态。凡是涉及 UI 状态的任务函数我都习惯问自己一句每条路径都能把状态归位吗3.3 异步处理和 UI 反馈细节因为AgentStore标了MainActor所以里面所有await恢复回来都在主线程直接改Published没风险。真正要注意的是技能执行本身别把主线程堵死。技能内部如果有重计算应该自己切到后台执行比如func execute(arguments: [String: Any]) async throws - String { let result await Task.detached(priority: .userInitiated) { // 重活在这里跑不占用主线程 return heavyCompute() }.value return result }视图层调用send时直接Task { await store.send(text) }就行。SwiftUI 里不建议在 Button 的 action 里写Task却不持有引用虽然短命任务无所谓但一旦用户在技能执行期间连续点发送就可能出现并发执行。我这里的处理是发送按钮在isThinking时禁用从交互层面杜绝重入。界面的消息气泡视图我尽量保持纯展示struct MessageBubble: View { let message: ChatMessage var body: some View { HStack { if message.role .user { Spacer(minLength: 48) } Text(message.text) .padding(10) .background(message.role .user ? Color.blue.opacity(0.12) : Color.gray.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 10)) if message.role .assistant { Spacer(minLength: 48) } } } }配合ScrollViewReader新消息进来时自动滚到底部。这套 UI 代码坦白说没什么技术含量但它验证了一个重要结论Agent 界面本质上还是消息列表复杂度全在状态流转上不在视图绘制上。4. 实操实录从零搭一个可运行的 Agent 技能 Demo4.1 工程准备和文件划分新建一个 iOS App 工程最低系统要求 iOS 17Swift 5.9 起步主要因为要用到any关键字和并发支持。文件划分如下Skill.swift技能协议定义。WeatherSkill.swift、TimerSkill.swift两个示例技能。SkillRegistry.swift注册中心。LLMService.swift模型服务协议抽象出请求技能决策和生成回复两个接口。AgentStore.swift状态与调度核心。ContentView.swift聊天界面。MessageBubble.swift消息气泡视图。模型服务我特意做成协议不绑定任何具体厂商。你在自己项目里接哪家模型都行只要实现requestToolCall和generateReply这两个方法即可。4.2 核心代码落地协议、注册表、Store、视图先看模型服务协议和调用结构体protocol LLMService { func requestToolCall(userText: String, skills: [any Skill]) async throws - ToolCall? func generateReply(toolResult: String, userText: String) async throws - String }requestToolCall返回ToolCall?当模型决定不调用任何技能时返回 nil或者返回一个带plainReply的纯文本响应。这样设计是为了兼容两种回应路径模型直接回答或者模型要求调用技能。接下来是两个技能实现。天气技能模拟真实天气 API 的行为倒计时技能展示参数默认值的处理struct WeatherSkill: Skill { var id: String { weather } var name: String { 天气查询 } var desc: String { 查询某个城市当前或未来几天的天气情况。当用户询问天气、气温、降水、风力等信息时调用。 } var parameterSchema: String { { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海 }, days: { type: integer, description: 查询未来几天默认 1最大 7 } }, required: [city] } } func execute(arguments: [String: Any]) async throws - String { guard let city arguments[city] as? String else { throw SkillError.missingParameter(city) } let days arguments[days] as? Int ?? 1 // 实际项目换成真实天气接口这里模拟返回 return \(city) 未来 \(days) 天以晴到多云为主最高气温 28°C最低 19°C无明显降水。 } }struct TimerSkill: Skill { var id: String { timer } var name: String { 倒计时 } var desc: String { 设置倒计时提醒。当用户要求倒计时、提醒、定时时调用。 } var parameterSchema: String { { type: object, properties: { seconds: { type: integer, description: 倒计时秒数必须大于 0 } }, required: [seconds] } } func execute(arguments: [String: Any]) async throws - String { let seconds arguments[seconds] as? Int ?? 0 guard seconds 0 else { throw SkillError.invalidValue(seconds 必须大于 0) } // 实际项目可以在这里安排本地通知这里简化处理 return 已设置 \(seconds) 秒倒计时时间到我会提醒你。 } }自定义错误枚举统一错误语义enum SkillError: LocalizedError { case missingParameter(String) case invalidValue(String) var errorDescription: String? { switch self { case .missingParameter(let name): return 缺少必要参数\(name) case .invalidValue(let reason): return 参数无效\(reason) } } }App 启动时把两个技能注册进去同时构造 Storemain struct AgentSkillDemoApp: App { StateObject private var store: AgentStore init() { let registry SkillRegistry() registry.register(WeatherSkill()) registry.register(TimerSkill()) let service MockLLMService() _store StateObject(wrappedValue: AgentStore(registry: registry, modelService: service)) } var body: some Scene { WindowGroup { ContentView(store: store) } } }最后是聊天界面。发消息按钮在isThinking时禁用输入框内容为空时也禁用struct ContentView: View { StateObject var store: AgentStore State private var inputText var body: some View { VStack(spacing: 0) { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(store.messages) { message in MessageBubble(message: message) .id(message.id) } } .padding() } .onChange(of: store.messages.count) { _, _ in if let lastID store.messages.last?.id { withAnimation { proxy.scrollTo(lastID, anchor: .bottom) } } } } Divider() HStack(spacing: 8) { TextField(输入你的问题比如北京明天天气、设置 60 秒倒计时, text: $inputText) .textFieldStyle(.roundedBorder) Button(发送) { let text inputText inputText Task { await store.send(text) } } .disabled(store.isThinking || inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .padding() } } }ChatMessage用一个简单的结构体struct ChatMessage: Identifiable, Equatable { enum Role { case user, assistant, system } let id UUID() let role: Role let text: String }这里用UUID()作 ID滚动定位时给每个气泡.id(message.id)才能精确滚动到最新条。4.3 跑通验证模拟输入到技能联动我用一个MockLLMService来本地验证流程不接真实模型也能把链路跑通struct MockLLMService: LLMService { func requestToolCall(userText: String, skills: [any Skill]) async throws - ToolCall? { if userText.contains(天气) || userText.contains(下雨) { return ToolCall( skillID: weather, arguments: [city: 北京, days: 1], plainReply: nil ) } if userText.contains(倒计时) || userText.contains(定时) { return ToolCall( skillID: timer, arguments: [seconds: 60], plainReply: nil ) } return ToolCall(skillID: nil, arguments: [:], plainReply: 我还不知道怎么处理这句话。) } func generateReply(toolResult: String, userText: String) async throws - String { return 技能执行完毕\(toolResult) } }把 App 跑起来在输入框里敲“查一下北京的天气”你会依次看到四条消息用户消息 - “正在调用技能天气查询” - 模型的最终回复“技能执行完毕北京未来 1 天以晴到多云为主……”。整个过程里按钮处于禁用状态isThinking状态把界面锁得死死的没有重入。这个 Demo 虽然逻辑简单但它完整跑通了“用户输入 - 模型决策 - 技能执行 - 结果回填”的核心链路后面换成真实模型服务无非是改LLMService的实现而已。5. 常见问题与排查技巧踩坑实录5.1 高频问题速查表现象原因解决办法模型始终返回普通文本从不触发技能技能描述太弱模型判断不出何时该调用把触发条件写进 desc例如“当用户询问天气时调用”技能被触发但参数总是缺这缺那JSON Schema 里 required 没写全显式声明 required并给参数补示例和默认值执行完技能后 UI 卡住不刷新在非主线程改动 PublishedStore 和 Registry 统一标注 MainActor模型一次请求里返回多个技能调用缺少约束模型以为可以并行触发在技能清单里注明“每次只允许选择一个技能”预览界面崩溃Store 在预览环境没有正确初始化为 Preview 单独构造 mock Registry 和 Service5.2 六条避坑指南第一协议里的Identifiable在any Skill场景下很容易踩坑。你不能直接写storage[skill.id]除非把协议约束成ID String。一开始我用的是Identifiable默认约束结果ID类型推导出some Hashable注册表根本没法拿字符串做 key后来才改成显式where ID String。第二skillsManifest不要无限膨胀。技能数量超过十几个时把完整 JSON Schema 全塞进上下文token 消耗很可观。我后来加了“启用/停用”标记让注册中心只输出已启用技能的清单这样既省 token也能避免模型在技能太多时产生混淆。第三技能执行结果最好给模型一个“定型”的机会但别让模型改写事实。我的做法是执行结果原样拼到系统消息里模型只负责拿它组织语言不负责“二次想象”。比如天气结果是“晴28°C”模型哪怕润色成“天气不错”数据部分仍然来自技能本身不会凭空多出温度。第四SwiftUI 的onChange写法在不同 iOS 版本有差别。iOS 17 之后是新签名onChange(of:) { _, _ in }旧签名已废弃但编译器只给警告如果你在低版本系统跑建议用旧签名或者包一层兼容。第五别把技能注册逻辑写在App的init里直接访问StateObject。我一开始想在init里修改store.registry结果收到运行时警告。正确做法是先构造好 Registry 和 Service再一次性传入StateObject(wrappedValue:)。第六单个技能内部一定做超时和取消处理。我见过一个同事的项目技能里发起网络请求却不在Task取消时终止导致用户退出界面后请求还在跑甚至回调时崩溃。现在我的所有技能execute都检查Task.isCancelled并把网络请求包装在支持取消的 API 里。最后再分享一个我在这个项目里验证过的小技巧给AgentStore加一个debugDescription把最近一轮对话的完整状态用户输入、模型决策、命中的技能、参数、执行结果拼成字符串打印出来。模型应用调试的难点在于“黑盒”你很难判断到底是哪一层出了问题有了这行日志技能没触发、参数抽错、执行失败一眼就能定位。这套 SwiftUI-Agent-Skill 的骨架本身不算复杂但把状态流转和技能注册这层地基打稳之后后续加技能、换模型、改界面都变成很轻松的事。

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

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

免费获取报价 →
↑