2026/10/14 9:19:55

SwiftUI Agent技能架构:可插拔技能体系与状态驱动设计

SwiftUI Agent技能架构:可插拔技能体系与状态驱动设计 看到“SwiftUI-Agent-Skill”这个标题不用过多解释你大概就能猜到这是一个跑在 SwiftUI 上的智能体项目并且专门给 Agent 设计了一套可插拔的“技能”体系。简单说就是把能力抽象成技能、把技能注册进一个总控、再让 Agent 在运行时决定调用哪个技能界面层只负责把用户输入收进来把输出结果呈上去。这样做最大的好处是业务能力可以独立演进UI 永远只是壳。我自己在写类似框架时最在意三件事一是新增技能时能不能做到不动旧代码二是几个技能同时生效时状态会不会乱三是 UI 能不能观察到 Agent 每一步在干什么。这个项目恰好就是往这三个方向走的适合那些已经在 SwiftUI 里做过几个应用、想尝试把大模型能力或者自动化任务做成模块化架构的开发者。接下来我会把技能协议、注册机制、调度编排、UI 集成一条线拆开讲全程带着代码示例和踩坑经验看完你完全可以照着搭一个自己的最小可运行版本。1. 项目拆解SwiftUI 端的 Agent 到底该长什么样1.1 为什么把“技能”单独拆出来很多人第一次接触 Agent 时最容易犯的错是把所有逻辑塞进一个巨大的 Manager 类初始化大模型客户端、写 prompt 拼接、调接口、做函数调用、再解析返回结果最后还要管界面状态。一开始只有两三个功能时觉得还行一旦技能超过五个这个类就会变成没人敢碰的“上帝对象”。把“技能”单独拆出来的本质是给 Agent 的能力做一个标准化接口。每个技能都有一致的输入输出、一致的注册方式、一致的执行方式调度器只依赖这个协议不关心技能内部是调大模型还是调系统 API。这样想添加新能力时只是往工程里放一个新文件、注册一下而不是去改一个千人捶的巨型类。我在实际项目里还发现一个额外好处技能拆出来后可以单独写单元测试、可以单独调试甚至可以做成独立模块分发给团队其他人。这对多人协作特别重要因为每个人只需要维护自己那一个技能冲突概率大幅降低。1.2 三个核心设计目标可插拔、可组合、可观测我给这个框架定了三个设计目标所有代码都是围绕它们展开的。第一个是可插拔。技能以协议方式存在注册表和具体实现解耦。运行时装了哪些技能Agent 就能用哪些技能不想用某个技能不注册就行。项目里如果有热更新需求甚至可以在运行时动态改变注册表内容。第二个是可组合。用户一句话往往触发多个能力比如“帮我写个会议纪要然后设好 10 分钟后提醒”前者是内容生成技能后者是系统提醒技能。设计上要让调度器能把多个技能串起来后一个技能能读到前一个技能的结果。组合能力决定了 Agent 是不是真的“智能”而不是单次调用的玩具。第三个是可观测。SwiftUI 是声明式 UI它天然适合把状态变成驱动视图的数据。Agent 在跑哪一步、当前在调用哪个技能、结果是什么都应该暴露给视图层。我们在设计状态模型时直接定义了 idle、thinking、executing、finished、failed 这些状态UI 拿到它就能完整渲染 Agent 的思考过程。1.3 整体模块划分模块上我建议分四层从底层到顶层分别是技能层具体能力的实现比如天气查询、定时器、文档生成。注册层维护技能 id 到实现的映射提供注册、查找、枚举接口。调度层解析用户输入决定调用顺序处理组合逻辑管理状态流转。呈现层SwiftUI 页面负责输入框、消息列表、技能执行状态面板。注意这里的“呈现层”不要包含任何业务判断只做展示。如果你发现视图里出现了 if 技能ID xxx 这样的代码说明分层已经出问题了。2. 先行第一步Skill 协议与注册机制2.1 技能协议怎么定最舒服协议是整个框架的地基定不好后面全是坑。我先给一版我调过很多次最终稳定下来的定义struct SkillContext { var query: String var params: [String: Any] [:] private var storage: [String: Any] [:] mutating func set(_ value: Any, forKey key: String) { storage[key] value } func valueT(forKey key: String) - T? { storage[key] as? T } } struct SkillResult { var text: String var data: [String: Any] [:] var suggestion: String? } protocol Skill { var id: String { get } var name: String { get } var description: String { get } var triggerKeywords: [String] { get } func execute(context: SkillContext) async throws - SkillResult }逐个说下为什么这样设计。id是技能唯一标识注册、查找、日志追踪都用它我一般直接使用字符串常量比如 “skill.weather.query”。description不一定用于界面展示更多是给后续接入大模型做工具描述时用大模型要靠这段描述决定用户需求对应哪个技能。triggerKeywords是给当前阶段用的简易匹配关键词后续如果接了大模型这个字段可以退化为辅助信息。执行方法用async throws是必须的任何技能都可能调网络、调系统服务、调用耗时计算异步是基础既然异步就一定会失败所以必须抛错误。用SkillContext而不是直接传一堆参数为的是技能之间能传递上下文这也是做组合能力的基础。2.2 注册表与动态装配注册表的设计很直接就是一个包了一层字典的服务定位器final class SkillRegistry { private var skills: [String: any Skill] [:] func register(_ skill: any Skill) { skills[skill.id] skill } func unregister(_ id: String) { skills.removeValue(forKey: id) } func skill(for id: String) - (any Skill)? { skills[id] } var allSkills: [any Skill] { Array(skills.values) } }这里有一个经常让人困惑的点[String: any Skill]和[String: some Skill]有什么区别。简单说some表示一个具体但匿名的类型一旦确定整个字典只能放同一种类型any表示任意满足协议的类型每个值可以是不同的实现。字典场景必须用any。有了注册表项目启动时把它们装配起来let registry SkillRegistry() registry.register(WeatherSkill()) registry.register(TimerSkill()) registry.register(NoteSkill())这个装配过程我建议放在 App 初始化阶段集中一个方法管理。你可能会看到很多人直接在视图中注册那样一旦视图重建注册表也被重置了运行时会出现“技能突然消失”的诡异问题。2.3 技能 id 的命名空间设计技能多了之后id 冲突是早晚的事。我见过两个人同时写一个 “weather” 技能互相把对方覆盖的情况。为了避免这类问题我直接用带层级的前缀命名skill.weather.query skill.weather.alert skill.timer.create skill.timer.cancel前缀其实就是命名空间同一类能力放同一段前缀下。查找时可以用前缀做批量操作比如“把所有skill.timer.*技能从注册表移除”。如果项目做到后面技能特别多还可以把注册表改成二级索引用前缀做分组统计。另一个建议是技能内部别把 id 写死字面量。我在一个统一常量文件里集中定义enum SkillID { static let weatherQuery skill.weather.query static let timerCreate skill.timer.create }这样写的好处是调度层和界面层引用技能 id 时都有编译期检查拼错字这种低级错误直接暴露。代价是多写几个常量但长远看非常划算。3. Agent 调度器的核心编排逻辑3.1 从用户输入到技能调用意图解析调度器在整个框架里相当于大脑入口。它接收用户输入先把输入匹配到候选技能再决定执行策略。初期不接大模型时最简单可靠的做法是关键词匹配struct IntentParser { func match(query: String, registry: SkillRegistry) - [any Skill] { let lower query.lowercased() return registry.allSkills.filter { skill in skill.triggerKeywords.contains { lower.contains($0.lowercased()) } } } }比如TimerSkill的关键词是[提醒, 定时, Timer]WeatherSkill的关键词是[天气, 气温, 下雨]。用户输入“早上八点提醒我开会”就能命中 TimerSkill。这个策略单看很笨但它最大的优点是稳定、可解释、零外部依赖适合做框架的第一版。如果项目想接大模型做意图识别我的建议是保留匹配层接口把内部实现替换为一个基于外部模型接口的RemoteIntentParser。这样框架结构不变只是把“文本到技能”的映射从本地规则升级成了模型决策。代码里可以这样留接口struct IntentParser { var matcher: (String, SkillRegistry) - [any Skill] func match(query: String, registry: SkillRegistry) - [any Skill] { matcher(query, registry) } }这样切换解析策略时只需要改初始化参数调度器本身完全不动。3.2 串行编排与并行编排命中多个技能之后调度器要决定怎么执行。当前阶段最常见的策略有三种我把它们直接实现成调度配置enum PipelineStrategy { case sequential case parallel case firstMatch }sequential适合多个技能有依赖关系的场景。比如先执行“生成文档”技能再把文档内容传给“发送邮件”技能。parallel适合彼此独立的技能同时启动比如同时查询天气和日程。firstMatch适合竞态场景多个技能谁能给出结果就用谁。实现层面我强烈建议遵循“调度器不感知具体技能逻辑只感知协议”这一原则。调度器只做三件事拿到技能列表、按策略执行、收集结果。下面是我实现的一个简化版final class AgentExecutor { func execute(_ skills: [any Skill], context: SkillContext, strategy: PipelineStrategy) async throws - [SkillResult] { switch strategy { case .sequential: var currentContext context var results: [SkillResult] [] for skill in skills { let result try await skill.execute(context: currentContext) results.append(result) if let data result.data as? [String: Any] { for (key, value) in data { currentContext.set(value, forKey: key) } } } return results case .parallel: return try await withThrowingTaskGroup(of: SkillResult.self) { group in for skill in skills { group.addTask { try await skill.execute(context: context) } } var results: [SkillResult] [] for try await result in group { results.append(result) } return results } case .firstMatch: var lastError: Error? for skill in skills { do { return [try await skill.execute(context: context)] } catch { lastError error } } throw lastError ?? AgentError.noSkillResult } } }串行编排时我把前一个技能返回的data全部写回SkillContext这样后面的技能就能通过context.value(forKey:)拿到前序结果。你可能会问为什么不直接改context.query因为query是用户原始输入改了后续日志和排查就乱了。额外的数据全部走storage语义更清晰。3.3 状态机让 Agent 变为可以被 UI 观察的事件流状态机这部分我踩过最大的坑是把 Agent 的执行状态放在各种 skill 内部结果 UI 完全不知道在等什么。现在我把状态收敛到调度器层面定义一个状态枚举enum AgentState { case idle case thinking case executing(skillID: String) case finished(String) case failed(Error) }调度器围绕这个状态做流转。开始执行前设置thinking每次要调用技能时设置executing(skillID:)全部完成后设置finished。UI 拿到这些状态就能展示 loading、技能名称、最终输出或错误提示。有个实操细节值得注意finished里我带的关联值是最终文本而不是直接把整个[SkillResult]丢出去。原因是视图层通常只需要展示给用户看的那一段话如果界面还想看每个技能的详细数据可以单独通过一个Published var history: [SkillExecutionRecord]去拿两者职责不同。4. SwiftUI 层怎么把这些东西串起来4.1 AgentEngine作为唯一状态源SwiftUI 里管理这个 Agent 的统一入口我命名为AgentEngine它本身是ObservableObject同时把状态全部Published暴露出来MainActor final class AgentEngine: ObservableObject { Published private(set) var state: AgentState .idle Published var messages: [ChatMessage] [] let registry: SkillRegistry let parser: IntentParser let executor: AgentExecutor func send(_ text: String) async { messages.append(.user(text)) let skills parser.match(query: text, registry: registry) guard !skills.isEmpty else { state .finished(没有匹配到可用的技能) messages.append(.agent(没有匹配到可用的技能)) return } var context SkillContext(query: text) state .thinking do { let results try await executor.execute(skills, context: context, strategy: .sequential) let finalText results.map { $0.text }.joined(separator: \n) state .finished(finalText) messages.append(.agent(finalText)) } catch { state .failed(error) messages.append(.agent(执行失败\(error.localizedDescription))) } } }把整个引擎标注为MainActor是个重要决定。这样AgentEngine里的属性和方法都跑在主 actor 上SwiftUI 视图直接读取Published时不需要额外的DispatchQueue.main.async包裹避免了大量线程切换导致的崩溃。4.2 技能列表和状态在界面里的展示有了AgentEngine界面变得非常薄。我通常拆成三个子视图消息流视图、技能面板视图、输入栏视图。技能面板的代码大概长这样struct SkillPanel: View { let registry: SkillRegistry Binding var enabledSkillIDs: SetString var body: some View { List(registry.allSkills, id: \.id) { skill in HStack { Text(skill.name) Spacer() Image(systemName: isEnabled(skill) ? checkmark.circle.fill : circle) } .contentShape(Rectangle()) .onTapGesture { toggle(skill) } } } }这里曝露了一个很现实的问题注册表把所有技能都加载出来了但 UI 需要能动态地开关技能让某些技能暂时不参与匹配。我在引擎里加了一个enabledSkillIDs: SetString匹配前先过滤一遍。这个开关纯粹是运行时的配置不修改注册表本身避免影响其他模块对注册表的读取。实际使用中这个技能面板特别适合做调试工具。把全部技能列出来看看哪些被加载了、哪些执行失败了比打日志直观得多。4.3 用 environmentObject 注入 AgentEngine让所有子视图都能拿到 AgentEngine比在每个视图初始化器里手动传参数要省事得多。做法是在 App 入口统一注入main struct AgentSkillApp: App { StateObject private var engine AgentEngine.makeDefault() var body: some Scene { WindowGroup { ContentView() .environmentObject(engine) } } }子视图里通过EnvironmentObject var engine: AgentEngine获取实例。如果你写过 UIKit可以把environmentObject类比为依赖注入容器视图树里的任何一个节点都能访问到它。这里有一个从 UIKit 转过来的开发者特别容易踩的坑SwiftUI 的EnvironmentObject在视图层级里找不到实例时会直接 crash而且报错信息不太友好。所以我的习惯是只在确定父视图已经注入的地方使用它如果组件可能脱离上下文独立使用就回退到普通属性传参。5. 实操复盘跑一个最小可用的 SwiftUI Agent5.1 工程结构和必要文件一个干净的最小工程我按下面的文件组织AgentSkillDemo/ ├── App/ │ └── AgentSkillApp.swift ├── Core/ │ ├── Skill.swift │ ├── SkillRegistry.swift │ ├── SkillContext.swift │ └── AgentEngine.swift ├── Skills/ │ ├── WeatherSkill.swift │ └── TimerSkill.swift └── Views/ ├── ContentView.swift ├── ChatView.swift ├── SkillPanel.swift └── InputBar.swift没必要用复杂的文件夹嵌套分层清楚就行。Core目录里放框架层代码Skills里放具体技能Views里放界面。这个结构我用了很久项目从零到几百个文件都能保持定位清晰。5.2 一个真实技能WeatherSkill为了让示例可跑我写一个本地模拟的天气技能它不做真实网络请求只演示执行链路struct WeatherSkill: Skill { var id: String { SkillID.weatherQuery } var name: String { 天气查询 } var description: String { 查询指定城市的当前天气概况 } var triggerKeywords: [String] { [天气, 气温, 下雨] } func execute(context: SkillContext) async throws - SkillResult { let city context.params[city] as? String ?? 北京 let mockWeather [晴, 多云, 小雨].randomElement() ?? 晴 let temperature Int.random(in: -5...28) return SkillResult( text: \(city) 当前天气\(mockWeather)气温 \(temperature)°C, data: [city: city, weather: mockWeather, temperature: temperature] ) } }注意看这里data的用法city和weather写进 data后续串行管道里的其他技能如果想读取“当前在聊哪个城市”直接context.value(forKey: city)就能拿到。这就是组合能力最小但最完整的一个演示。5.3 启动和首次运行的整体体验编译通过后第一次运行我们会看到这样的流程第一个页面是聊天界面底部一个输入框。输入“北京天气怎么样”引擎会匹配到 WeatherSkill然后state先变成thinking界面出现 loading 指示器随后变成executing(skillID:)消息列表上方显示当前技能名称技能执行完finished状态写回消息列表出现一条模拟的天气结果。这个过程中请特别留意控制台打印的顺序。如果发现消息插入和状态变化顺序不对多半是并发问题。我遇到过一种情况两次快速发送消息后面一次的结果先回来了导致消息顺序颠倒。处理办法是为每条消息加递增序号struct ChatMessage: Identifiable { let id UUID() let sequence: Int let role: MessageRole let text: String }引擎用一个计数器维护sequence视图排序时按它来。这比单纯依赖id更可靠因为UUID不保证顺序。6. 常见问题排查与避坑清单6.1 编译期报错Protocol Skill cannot be used as a generic constraint这是很多第一次写any Skill时最容易撞见的编译错误尤其是在 Swift 5.6 之前。实际上protocol Skill { func execute(context:) async throws - SkillResult }在用any时是没问题的但如果在泛型函数里写了T: Skill然后再去操作[T]就会出现类型擦除相关的报错。我的建议很简单不要在这一层过度使用泛型直接用any Skill作为类型。除非你很清楚协议中还有 associatedtype 需要处理。对于目前的框架any已经够用而且代码读起来更简单。提示如果你的项目部署版本较老遇到 “async” 相关语法不支持需要确认 Xcode 版本和 Swift 版本。这类问题通常升级构建工具版本即可尽量不要用 workaround 绕过去。6.2 MainActor 和异步任务之间的崩溃状态管理里最常见的崩溃是后台任务执行完成后直接修改了Published属性。虽然AgentEngine标记了MainActor但如果你在技能内部开了自己的 Task然后回调里访问引擎状态仍然可能触发 runtime 崩溃。我踩过的一次真实情况是某个技能内部用DispatchQueue.global()做耗时数据处理处理完直接调用了引擎方法。解决方案不是在每处硬套线程切换而是约束技能内部不要直接接触引擎对象只把结果通过返回值传递。引擎在调用技能时让它自然在后台执行最终把数据返回后由引擎在主 actor 上下文中更新状态。如果你确实要在技能里回调推荐用Task { MainActor in ... }包裹而不是用DispatchQueue.main.async因为前者能通过编译器保证 actor 隔离。6.3 技能“神秘消失”的排查前面提到注册表被重置的问题我再详细说一种现象进了某个二级页面返回后发现 Agent 能用的技能变少了。多半是注册表被某个视图通过State持有视图销毁后注册表也被释放了。解决办法是把注册表的所有权上升到 App 层级通过StateObject持有这样才能保证和 App 生命周期一致。排查时可以打印注册表数量print(当前注册技能数\(registry.allSkills.count))如果数字时多时少就能立刻定位到生命周期问题。6.4 快速排查速查表现象大概率原因处理思路Agent 提示“没有匹配到可用的技能”关键词没覆盖到输入检查 triggerKeywords 或匹配逻辑的大小写处理技能执行完但 UI 不更新引擎未注入到视图层级检查 environmentObject 注入点消息顺序错乱异步结果返回顺序不一致为消息加 sequence 排序状态卡在 thinking技能内部死循环或等了太久给技能执行加超时机制技能列表时有时无注册表生命周期被视图持有把注册表提升到 App 级持有我强烈建议给每个技能加一个可取消的 timeout 机制。最简单的方式是用Task包装执行方法超过指定时间就抛错。原因是真实场景里某个外部服务很可能会挂掉没有超时整个 Agent 就卡在“思考中”体验很糟糕。7. 最后再分享几个我在实际使用中沉淀下来的小技巧这个框架跑通之后下一个阶段我建议你做三件事一是把IntentParser的匹配结果和最终执行结果全部记录到本地日志文件方便复盘二是给技能执行加上耗时统计每个技能完成了多少、耗时多少都展示在技能面板上能帮你快速发现异常缓慢的技能三是谨慎引入外部依赖这个框架的核心代码尽量保持无第三方库后续扩展才不会被历史包袱卡住。我个人的体会是SwiftUI 做 Agent 界面真正的难点不在 UI 布局而在状态管理。只要把技能协议定好、状态演进收敛到单一引擎、UI 只做状态投射整个项目的复杂度就会直线下降。如果你正准备在下一个项目里加一个智能助手模块不妨先按这个最小骨架搭一版跑通后再往里填真正的业务技能。届时你会发现加一个新技能真的只需要写一个文件、注册一行代码。