ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

最近我把一个折腾了两三周的 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 还是 @Observable

SwiftUI 里管全局状态,老牌方案是ObservableObject+@Published,新的是 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 技能 Demo

4.1 工程准备和文件划分

新建一个 iOS App 工程,最低系统要求 iOS 17,Swift 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 启动时把两个技能注册进去,同时构造 Store:

@main 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 和 Service

5.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 的骨架本身不算复杂,但把状态流转和技能注册这层地基打稳之后,后续加技能、换模型、改界面都变成很轻松的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进