1. 这个标题到底在说什么
第一次看到“SwiftUI-Agent-Skill”这个组合词,我脑子里蹦出来的第一反应是:这大概率不是一个单纯的UI框架教程,也不是一个泛泛的AI概念科普,而是把三个东西拧在了一起——SwiftUI的声明式界面能力、Agent的自主决策与任务编排能力、以及Skill的模块化封装思想。说白了,它想解决的核心问题是:如何让一个具备自主行为能力的智能体,在苹果生态里拥有一套可复用、可组合、可扩展的“技能包”,并且用SwiftUI把整个交互过程可视化、可调试、可落地。
我之所以这么判断,是因为最近半年在移动端和桌面端做智能交互原型的圈子里,大家讨论最多的不再是“怎么调一个模型接口”,而是“怎么让模型真正驱动界面状态”。传统的做法是:用户点按钮,代码发请求,拿到结果后手动更新UI。这套流程在简单场景下没问题,但一旦涉及多轮决策、工具调用、状态回滚、异步任务编排,代码就会迅速膨胀成一团乱麻。SwiftUI-Agent-Skill这个方向,本质上是在尝试用声明式状态驱动的思路,把Agent的行为逻辑和界面渲染统一到同一个数据流里。
这篇文章适合谁看?如果你是一个有SwiftUI基础、想往智能交互方向走的iOS/macOS开发者,或者你正在做本地优先的AI助手类产品,再或者你只是对“Agent怎么和UI框架结合”这件事感到好奇,那接下来的内容应该能给你一些可以直接抄作业的思路。我不会只讲概念,我会把架构拆解、关键代码、参数选择、踩坑记录都摊开来说。需要提前说明的是,文中涉及的具体实现细节,有一部分是基于我在类似项目中的常见实践做的合理补全,因为原始标题只给了一个方向,没有附带完整代码仓库。
2. 整体架构设计与思路拆解
2.1 为什么要把Agent和SwiftUI绑在一起
先聊一个根本问题:Agent逻辑和UI逻辑到底该不该放在一起?我的答案是,在原型阶段和中小型产品里,应该放在一起,而且越紧密越好。原因很简单,Agent的核心特征是“状态不确定”——它可能正在思考、正在调用工具、正在等待用户确认、正在回滚上一步操作。这些状态如果散落在ViewModel、Service、Coordinator多个层级里,调试成本会高到让你怀疑人生。
SwiftUI的@State、@Observable、@Binding这套机制,天然适合表达“状态变化驱动界面更新”。而Agent的执行流程,本质上就是一个状态机:空闲、规划中、执行中、等待输入、完成、失败。把这两个状态机合并成一个,用SwiftUI的Observation框架来管理,代码量会大幅下降,而且界面能实时反映Agent的每一步动作。
我试过把Agent逻辑放在单独的Service层,然后用Combine的Publisher往UI层推事件。结果是:事件顺序难以保证,错误处理分散在各处,回滚操作几乎没法做。后来改成用一个@Observable的AgentState对象统一持有所有状态,SwiftUI视图直接读这个对象,问题就少了很多。这也是我推荐SwiftUI-Agent-Skill这个方向的核心原因:状态统一,行为可追溯,界面即调试器。
2.2 Skill模块化的设计哲学
Skill这个词在这里不是随便起的。一个Agent如果什么都能干,那它什么都干不好。Skill的本质是能力边界——每个Skill只负责一类任务,有明确的输入输出契约,可以独立测试,可以按需加载。
我见过很多项目把Agent写成一个巨大的switch-case,用户说天气就调天气API,用户说提醒就调日历API,所有逻辑堆在一个文件里。这种写法在demo阶段没问题,但一旦要加新能力,就要动核心代码,风险极高。Skill模块化的思路是:每个Skill是一个独立的struct或class,遵循同一个协议,Agent只负责“选择哪个Skill”和“传递什么参数”,具体执行由Skill自己完成。
在SwiftUI环境下,Skill还有一个额外的好处:每个Skill可以自带UI片段。比如“天气查询Skill”可以返回一个天气卡片视图,“日程创建Skill”可以返回一个确认表单视图。Agent决定调用哪个Skill后,SwiftUI直接渲染对应的视图,交互体验非常连贯。
2.3 数据流与状态管理选型
在SwiftUI里做状态管理,目前主流有三条路:ObservableObject+@Published、新的@Observable宏、以及@State配合值类型。我的建议是:Agent的核心状态用@Observable类来持有,Skill的局部状态用@State值类型来管理。
为什么这么分?Agent状态是跨视图共享的,需要被多个界面读取和修改,用引用类型更合适。Skill状态通常是某个视图内部的临时状态,比如表单输入、加载指示器,用值类型更安全,不会意外泄漏到外部。另外,@Observable相比ObservableObject的性能优势很明显——它只追踪实际被读取的属性,不会因为一个无关属性变化就触发整个视图刷新。这在Agent频繁更新状态的场景下非常关键。
还有一个细节:Agent的执行往往是异步的,涉及async/await。我习惯把Agent的主循环放在一个@MainActor隔离的类里,确保所有状态更新都在主线程。Skill内部的耗时操作则放到后台线程,通过await回到主线程更新状态。这样既保证了UI流畅,又避免了数据竞争。
3. 核心细节解析与实操要点
3.1 Agent状态机的定义与流转
Agent的状态机是整个系统的骨架。我一般会定义这样一个枚举:
enum AgentPhase { case idle case planning case executing(skillID: String) case waitingForUser(prompt: String) case completed(result: String) case failed(error: String) }这个枚举看起来简单,但每个case背后都有讲究。idle是初始状态,也是每次任务完成后的回归状态。planning表示Agent正在决定下一步做什么,这个阶段通常涉及一次模型调用或规则匹配。executing携带了当前执行的Skill ID,方便UI显示“正在执行XX技能”。waitingForUser是关键状态,表示Agent需要用户确认或补充信息,这时候UI要弹出对应的交互组件。completed和failed是终态,但失败后通常允许重试。
状态流转的规则必须严格定义。比如,不能从idle直接跳到executing,必须先经过planning。不能从completed直接回到executing,必须先重置到idle。这些约束看起来繁琐,但能避免大量边界bug。我踩过的坑是:早期没有严格约束流转,结果Agent在失败后直接重试,跳过了规划阶段,导致重复调用同一个Skill,浪费了大量token和时间。
注意:状态机的流转最好用一个集中的
transition(to:)方法来控制,所有状态变更都走这个方法,方便打日志和做断言。
3.2 Skill协议的设计与实现
Skill协议是整个模块化架构的核心契约。我通常会这样定义:
protocol AgentSkill { var id: String { get } var name: String { get } var description: String { get } var triggerKeywords: [String] { get } func canHandle(input: String) -> Bool func execute(input: String, context: AgentContext) async throws -> SkillResult }id和name用于标识和展示。description是给Agent规划阶段看的,帮助它判断该选哪个Skill。triggerKeywords是快速匹配用的,可以在不调用模型的情况下先做一轮筛选。canHandle是更精细的判断,比如“天气Skill”只有在输入包含城市名时才返回true。execute是实际执行逻辑,返回一个SkillResult,里面包含要展示的文本、要渲染的视图类型、以及可能的后续动作建议。
这里有个设计决策值得展开:SkillResult要不要包含UI信息?我的选择是要。因为SwiftUI环境下,Skill如果只返回纯文本,UI层还要再写一套映射逻辑,很啰嗦。让Skill直接返回一个View或者一个视图描述符,UI层直接渲染,效率高很多。但要注意,Skill返回的View应该是轻量的、无状态的,不要在里面做耗时操作。
struct SkillResult { let displayText: String let viewType: SkillViewType let payload: [String: Any] let suggestedNextActions: [String] }suggestedNextActions这个字段很有用。比如“天气Skill”执行完后,可以建议“设置天气提醒”或“查看未来一周”。Agent拿到这些建议后,可以在UI上展示为快捷按钮,用户点一下就能触发下一个Skill,体验非常顺滑。
3.3 规划器的实现策略
规划器负责决定“下一步调用哪个Skill”。有两种主流策略:基于规则的匹配和基于模型的推理。我的建议是两者结合,先用规则做快速筛选,再用模型做精细决策。
规则匹配很简单:遍历所有已注册的Skill,调用canHandle,收集所有返回true的Skill。如果只有一个,直接执行。如果有多个,进入模型推理阶段。如果零个,返回“无法处理”或触发兜底Skill。
模型推理阶段,我会构造一个提示词,把所有候选Skill的name和description列出来,让模型选择最合适的一个,并提取参数。提示词大概长这样:
你是一个技能调度器。用户输入是:{input} 可用技能如下: - 天气查询:查询指定城市的当前天气和预报 - 日程创建:在日历中创建新事件 - 备忘录:创建或查询备忘录 请选择最合适的技能,并以JSON格式返回:{"skill_id": "...", "params": {...}}这里的关键是输出格式约束。一定要让模型返回结构化数据,不要返回自然语言。我早期偷懒让模型直接返回技能名,结果它有时候返回“天气查询技能”,有时候返回“weather”,解析起来很痛苦。后来强制JSON格式,配合JSONDecoder,稳定性大幅提升。
实操心得:在提示词里加一句“如果没有任何技能匹配,返回skill_id为null”,这样能优雅处理无法识别的情况,而不是让模型硬选一个。
4. 实操过程与核心环节实现
4.1 项目结构搭建
我习惯这样组织目录:
SwiftUIAgentSkill/ ├── Agent/ │ ├── AgentState.swift │ ├── AgentPlanner.swift │ └── AgentExecutor.swift ├── Skills/ │ ├── AgentSkill.swift │ ├── WeatherSkill.swift │ ├── CalendarSkill.swift │ └── NoteSkill.swift ├── Views/ │ ├── AgentChatView.swift │ ├── SkillResultView.swift │ └── SkillInputView.swift └── App/ └── SwiftUIAgentSkillApp.swiftAgent目录放核心逻辑,Skills目录放各个技能实现,Views目录放界面,App目录放入口。这个结构的好处是职责清晰,加新Skill只需要在Skills目录新建文件,然后在注册中心加一行。
注册中心我一般用一个简单的数组:
@MainActor final class SkillRegistry { static let shared = SkillRegistry() private(set) var skills: [AgentSkill] = [] func register(_ skill: AgentSkill) { skills.append(skill) } func allSkills() -> [AgentSkill] { skills } }在App启动时注册所有Skill:
@main struct SwiftUIAgentSkillApp: App { init() { SkillRegistry.shared.register(WeatherSkill()) SkillRegistry.shared.register(CalendarSkill()) SkillRegistry.shared.register(NoteSkill()) } var body: some Scene { WindowGroup { AgentChatView() } } }4.2 Agent主循环的实现
Agent主循环是整个系统的心脏。我把它写成一个@Observable类:
@MainActor @Observable final class AgentController { var phase: AgentPhase = .idle var messages: [AgentMessage] = [] var currentResult: SkillResult? private let planner = AgentPlanner() private let executor = AgentExecutor() func handleUserInput(_ input: String) async { messages.append(.user(input)) phase = .planning do { let plan = try await planner.plan(input: input, skills: SkillRegistry.shared.allSkills()) switch plan { case .execute(let skillID, let params): phase = .executing(skillID: skillID) let result = try await executor.execute(skillID: skillID, params: params) currentResult = result messages.append(.agent(result.displayText)) phase = .completed(result: result.displayText) case .askUser(let prompt): phase = .waitingForUser(prompt: prompt) messages.append(.agent(prompt)) case .noMatch: phase = .failed(error: "没有找到合适的技能") messages.append(.agent("抱歉,我暂时无法处理这个请求。")) } } catch { phase = .failed(error: error.localizedDescription) messages.append(.agent("执行出错:\(error.localizedDescription)")) } } }这段代码有几个关键点。第一,所有状态更新都在@MainActor上,保证线程安全。第二,planning阶段调用planner.plan,返回一个枚举,明确区分“执行技能”“询问用户”“无法匹配”三种情况。第三,执行完成后把结果存入currentResult,UI层根据这个值决定渲染什么视图。
4.3 天气Skill的完整实现
拿天气Skill举例,展示一个完整Skill应该长什么样:
struct WeatherSkill: AgentSkill { let id = "weather" let name = "天气查询" let description = "查询指定城市的当前天气和未来预报" let triggerKeywords = ["天气", "气温", "下雨", "晴", "阴"] func canHandle(input: String) -> Bool { triggerKeywords.contains { input.contains($0) } } func execute(input: String, context: AgentContext) async throws -> SkillResult { let city = extractCity(from: input) ?? "北京" let weather = try await WeatherAPI.fetch(city: city) return SkillResult( displayText: "\(city)当前\(weather.condition),气温\(weather.temperature)度。", viewType: .weatherCard, payload: ["city": city, "temperature": weather.temperature, "condition": weather.condition], suggestedNextActions: ["查看未来一周", "设置天气提醒"] ) } private func extractCity(from input: String) -> String? { // 简单实现:匹配常见城市名 let cities = ["北京", "上海", "广州", "深圳", "杭州", "成都"] return cities.first { input.contains($0) } } }extractCity这里用了最简单的匹配,实际项目中可以用更复杂的NLP或者让模型提取。但要注意,参数提取不要过度依赖模型,能用规则解决的先用规则,模型只处理规则覆盖不了的情况。这样既快又省。
4.4 SwiftUI视图层的渲染逻辑
视图层根据phase和currentResult决定渲染什么:
struct AgentChatView: View { @State private var controller = AgentController() @State private var inputText = "" var body: some View { VStack { ScrollView { ForEach(controller.messages) { message in MessageBubble(message: message) } if case .executing(let skillID) = controller.phase { HStack { ProgressView() Text("正在执行:\(skillID)") } } } if let result = controller.currentResult { SkillResultView(result: result) } HStack { TextField("输入指令...", text: $inputText) Button("发送") { Task { await controller.handleUserInput(inputText) inputText = "" } } } } } }SkillResultView根据result.viewType渲染不同的卡片:
struct SkillResultView: View { let result: SkillResult var body: some View { switch result.viewType { case .weatherCard: WeatherCardView(payload: result.payload) case .calendarForm: CalendarFormView(payload: result.payload) case .noteCard: NoteCardView(payload: result.payload) default: Text(result.displayText) } } }这种设计的好处是,加新Skill只需要加一个新的viewType和对应的视图,不需要改动主流程。扩展性非常好。
5. 常见问题与排查技巧实录
5.1 状态更新不及时导致UI卡顿
这是最常见的问题。表现是:Agent明明已经进入executing状态了,但UI上还显示planning。原因通常是状态更新没有在主线程,或者@Observable对象被意外复制了。
排查步骤:第一,确认AgentController是@MainActor隔离的。第二,确认在View里用的是@State而不是@StateObject(@Observable配合@State使用)。第三,检查有没有在后台线程直接修改phase属性。我遇到过一次,是在Task.detached里改了状态,结果UI完全不刷新。改成Task { @MainActor in ... }就好了。
避坑技巧:在AgentController里加一个
didSet观察器,打印每次状态变更的线程信息,能快速定位问题。
5.2 Skill选择错误或重复调用
有时候Agent会选错Skill,或者连续调用同一个Skill。原因可能是description写得太模糊,或者triggerKeywords有重叠。
解决办法:第一,把每个Skill的description写得尽量具体,包含典型使用场景。第二,triggerKeywords尽量用长词,避免用“查”“看”这种太泛的词。第三,在规划器里加一个“最近调用记录”,如果同一个Skill在短时间内被连续调用,强制进入waitingForUser状态,让用户确认。
我实测下来,把description从“查询天气”改成“查询指定城市的当前天气状况和未来几天预报,适用于用户询问气温、降水、穿衣建议等场景”之后,选择准确率明显提升。
5.3 异步任务取消与超时处理
Agent执行Skill时,用户可能中途取消,或者Skill本身超时。如果不处理,会导致状态卡在executing永远出不来。
我的做法是:在AgentController里持有一个Task引用,用户点取消时调用task.cancel()。Skill的execute方法里用try Task.checkCancellation()定期检查取消状态。超时则用withThrowingTaskGroup配合Task.sleep实现:
func executeWithTimeout(seconds: TimeInterval) async throws -> SkillResult { try await withThrowingTaskGroup(of: SkillResult.self) { group in group.addTask { try await self.execute(...) } group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw AgentError.timeout } let result = try await group.next()! group.cancelAll() return result } }5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| UI不刷新 | 状态更新不在主线程 | 打印线程信息 | 用@MainActor隔离 |
| Skill选错 | description模糊 | 检查提示词 | 细化description |
| 重复调用 | 缺少调用记录 | 加日志 | 加最近调用检查 |
| 状态卡死 | 未处理取消/超时 | 检查Task生命周期 | 加超时和取消 |
| 内存泄漏 | 循环引用 | 用Instruments | 用weak/unowned |
| 参数提取失败 | 规则覆盖不足 | 看输入样本 | 补充规则或加模型提取 |
6. 扩展方向与个人经验
这套架构跑通之后,扩展空间其实很大。我目前尝试过的几个方向包括:多Skill串联,比如天气Skill执行完后自动触发提醒Skill;Skill优先级,给每个Skill加一个priority字段,规划器优先选高优先级的;本地缓存,把Skill结果缓存起来,相同输入直接返回,省token也省时间。
还有一个我觉得很有潜力的方向是Skill的市场化。如果每个Skill都是独立模块,那理论上可以做一个Skill商店,用户按需下载。当然这涉及签名、权限、沙箱等一堆问题,但架构上已经预留了可能性。
最后分享一个小技巧:在开发阶段,我会给AgentController加一个debugMode开关,打开后每次状态变更都打印详细日志,包括当前phase、调用的Skill、耗时、token消耗。这个日志在排查问题时比断点还好用,因为Agent的执行往往是异步的,断点会打乱时序。
另外,如果你打算把这套东西用到生产环境,一定要做降级策略。模型调用失败时,退回到纯规则匹配;规则也匹配不上时,给用户一个友好的提示,而不是直接报错。我见过太多项目在demo阶段很惊艳,一上生产就因为网络波动或模型限流而崩溃。稳健性比炫技重要得多。