资讯动态

SwiftUI智能体技能框架:跨端AI应用的分层架构与调度实践

发布时间:2026/10/14 8:44:56 来源:尧图企业网站定制
1. 项目概述与设计初衷1.1 这个项目到底是什么SwiftUI-Agent-Skill从名字上拆开就是三件事SwiftUI 负责界面Agent 负责调度Skill 负责具体能力。我当时做这个项目的出发点特别简单——想把一堆零散的 AI 能力文本摘要、关键词提取、简单对话、本地知识库检索这些封装成一个个独立的技能模块然后用一个统一的智能体去调度它们最后通过 SwiftUI 搭一个干干净净的跨端界面。你看如果直接用官方 API 或者单独调用各种模型代码会很快腐化成一锅粥。每个功能都要单独处理网络请求、状态管理、错误提示界面逻辑和业务逻辑全揉在一起。而「Agent-Skill」这种模式等于给所有功能建立了统一的入口和出口界面只跟 Agent 打交道Agent 再去分发任务给具体技能。这个思路其实早在服务端 AI 应用里被验证过无数次了我只是把它搬到了苹果生态里。这个项目适合谁参考如果你正在用 SwiftUI 写一个带 AI 功能的 App或者你手上有个老项目想重构出干净的 AI 功能层再或者你就是想搞明白Agent 和 Skill 之间到底怎么通信那这篇文章能给你一套可以直接落地的方案。我不只讲代码还会把设计取舍和踩坑记录一并放出来。1.2 为什么用 SwiftUI 而不是 UIKit这个决定恐怕是最不需要犹豫的。SwiftUI 的声明式语法天然适合做状态驱动的界面——Agent 的所有输出本质上都是一堆异步状态SwiftUI 的State、Observable和async/await配合起来几乎没有多余的样板代码。举个例子我在项目里需要展示技能执行的中间状态开始执行、正在处理、部分结果返回、最终完成。用 UIKit 你得手动刷新几个 label 或者 collectionView而在 SwiftUI 里只是一个enum状态的切换界面自动跟着变。整个项目里我几乎没有手动刷新过界面这对一个大量异步操作的 AI 工坊型项目来说省下来的心力远超想象。另外值得一提的是 SwiftUI 的跨端能力。我最初只在 iPhone 上跑后来发现同样的代码几乎不用改就能在 iPad 和 Mac 上跑得有模有样。Agent 面板在 Mac 上还能利用更大的屏幕做双栏布局这是 UIKit 时代想都不敢想的成本。1.3 Agent 和 Skill 的职责边界在动手之前最重要的问题是分清边界。Agent 不是什么都干它只负责三件事理解需求、拆解任务、调度技能。Skill 也只干一件事把某个具体的 AI 能力变成标准化的接口。打个比方Agent 就像餐厅里的前台经理你来点菜他记录需求、安排后厨做菜Skill 就是后厨里一个个灶台一个灶台只做一道拿手菜。前台经理不需要会颠勺灶台也不用管顾客怎么点单。这个思维模型的直接好处是你想加一个新技能只需要加一个灶台其他什么都不用动。我在项目里定义了一个AgentSkill协议所有技能都必须遵守这个协议。这个设计是整个项目的基石后面的章节我会详细拆解它是怎么写出来的。2. 核心架构拆解2.1 整体分层设计整个项目我分成了四层界面层SwiftUI View、调度层Agent Service、能力层Skill 实现、基础设施层网络、缓存、日志。界面层只做一件事把用户输入丢给调度层然后把调度层返回的状态渲染出来。调度层是大脑解析意图、选择技能、管理并发、汇总结果。能力层每个 Skill 独立完成一个任务对外暴露标准接口。基础设施层负责所有通用的脏活累活API 调用、超时控制、数据持久化。这个分层的核心价值在于依赖方向是单向的。界面层不知道也不关心技能是怎么实现的调度层不关心界面长什么样技能层不知道用户点的是按钮还是喊的语音。你可以在不改变其他层的情况下替换任何一层这种自由度在 AI 功能迭代极快的当下不是奢侈是刚需。举一个具体的例子我最初用的某种云端大模型接口只支持文本输入后来换成支持多模态的模型技能层只需要改内部实现对外暴露的协议不变界面和调度层完全不需要动。2.2 Skill 协议的设计协议是让系统活起来的关键。下面是我最终敲定的AgentSkill协议在 Swift 里定义成这样protocol AgentSkill: AnyObject { /// 技能的唯一标识 var id: String { get } /// 技能的名字 var displayName: String { get } /// 技能描述用于 Agent 做意图匹配 var description: String { get } /// 技能支持的输入类型 var inputType: SkillInputType { get } /// 技能执行的入口 func execute(input: SkillInput) async throws - SkillOutput }这里每一个字段都不是随便定的。id是注册中心的 KeydisplayName是给用户看的description是最关键的字段——Agent 就是靠它来决定该调用哪个技能的。inputType和execute一起定义了技能的统一接口SkillInput和SkillOutput是两个包装类型底层可以装字符串、图片、文件数据等等。我在第一版设计里犯过一个错把description写得很笼统比如处理文本。结果调度的时候 Agent 经常选错技能。后来我把描述写成了像从长文章中提取关键观点返回一段不超过五个点的摘要适合用于快速阅读这样详细的语句匹配准确率一下子提升了很多。这个经验帮助我意识到description就是技能的名片必须写清楚能力和边界。2.3 Agent 调度核心逻辑Agent 的核心是一个调度器我把它实现成一个AgentEngine类。它的基本流程是接收用户的自然语言输入 → 在注册表中寻找最匹配的技能 → 调用技能执行 → 把结果返回给界面层。final class AgentEngine { private var skills: [String: any AgentSkill] [:] func register(_ skill: any AgentSkill) { skills[skill.id] skill } func dispatch(query: String) async throws - SkillOutput { // 第一步技能筛选 let candidates skills.values.filter { skill in skill.description.localizedCaseInsensitiveContains(query) } guard let best candidates.first else { throw AgentError.noSkillMatched } // 第二步构造输入 let input SkillInput(text: query) // 第三步执行并返回 return try await best.execute(input: input) } }当然真实项目比这段复杂得多。我做了一个简单的意图分类——用关键词权重给每个技能打分选出最高分的那个。有些项目会直接让大模型来做路由决策这也是一种思路但考虑到响应速度和成本我当时更倾向于轻量级规则匹配。如果你希望 Agent 能理解更复杂的语义在dispatch这一步调用一次大模型做函数调用Function Calling是完全可行的架构上只需要替换掉第一段筛选逻辑。这里想分享一个经验调度结果最好做成异步流AsyncStream因为技能执行过程中可能产生多个中间状态比如正在检索、正在生成。界面层可以流式地展示这些状态用户会感觉 Agent 更活了而不是一顿干等。3. 从零搭建的实操过程3.1 环境准备与项目创建关于工具链我使用的是 Xcode 16 和 iOS 17 的 SDKSwiftUI 的Observable宏在这个版本里特别好用。如果你还在用更老的版本建议先把工具链升上来因为后面很多代码都依赖新特性。创建项目时选择 App 模板Interface 选择 SwiftUI。项目名称我起了个内部代号你随意即可。最小支持版本设到 iOS 17这是因为Observable和AsyncStream的流畅体验需要它。项目结构按分层建目录我是这样组织的SwiftUIAgentSkill/ ├── App/ │ └── SwiftUIAgentSkillApp.swift ├── Views/ │ ├── ChatView.swift │ ├── SkillListView.swift │ └── Components/ ├── Agent/ │ ├── AgentEngine.swift │ └── SkillProtocol.swift ├── Skills/ │ ├── SummarizeSkill.swift │ ├── KeywordSkill.swift │ ├── TranslateSkill.swift │ └── KnowledgeSkill.swift └── Infrastructure/ ├── LLMClient.swift └── Models/一开始就建好清晰的目录比什么都重要。我见过太多项目写着写着 View 文件夹里堆了几十个文件完全没法维护。3.2 SwiftUI 界面的关键实现界面部分是大多数 SwiftUI 开发者最熟悉的部分我重点讲几个跟 Agent 项目强相关的点。首先是状态管理。我用Observable创建一个全局的 AppModelObservable final class AppModel { var messages: [ChatMessage] [] var isAgentRunning false var currentSkill: (any AgentSkill)? var errorMessage: String? private let engine AgentEngine() func send(_ text: String) async { messages.append(ChatMessage(role: .user, content: text)) isAgentRunning true defer { isAgentRunning false } do { let output try await engine.dispatch(query: text) let answer output.extractText() messages.append(ChatMessage(role: .assistant, content: answer)) } catch { errorMessage error.localizedDescription } } }界面层用Environment拿到这个模型直接在视图里调用send方法。整个过程没有 delegate、没有 NotificationCenter、没有 Combine 的 sink所有状态都是单向流动的。聊天界面的核心结构是 ScrollView LazyVStack每条消息根据角色左对齐或右对齐。这里没太多难点唯一提醒的是长文本建议用Text的selectable修饰符让用户能选中复制结果这个小细节会明显提升体验。3.3 编写一个真实可用的 Skill光说概念不够我拿文本摘要技能做个完整示例。这个技能做的事调用大模型接口把长篇内容压缩成几个要点。final class SummarizeSkill: AgentSkill { let id skill.summarize let displayName 摘要总结 let description 从长文章中提取关键观点返回简洁的摘要适合用于快速了解内容 let inputType: SkillInputType .text private let client: LLMClient init(client: LLMClient) { self.client client } func execute(input: SkillInput) async throws - SkillOutput { guard let text input.text, !text.isEmpty else { throw SkillError.invalidInput } let truncated String(text.prefix(3000)) let prompt 请阅读以下内容提取最核心的观点用不超过五个要点输出。 要求语言简洁、逻辑清晰、每一点不超过五十个字。 内容如下 \(truncated) let result try await client.complete(prompt: prompt) return SkillOutput(text: result) } }这里有一个很现实的细节大模型 API 对输入长度有限制所以我在execute里加了一个截断逻辑把超长文本裁到安全范围。不同模型有不同上下文窗口我这里按 3000 字符来截稳妥够用。如果你接的是更大上下文的模型可以放宽这个值。还有一件事值得做给execute方法加超时控制。我封装在LLMClient内部用Task加超时抛出错误避免某个技能卡死拖垮整个 Agent。3.4 技能的注册与意图路由写好了技能怎么让它被 Agent 感知答案是注册。我写了一个SkillRegistry单例专门管理所有技能final class SkillRegistry { static let shared SkillRegistry() private(set) var allSkills: [any AgentSkill] [] func register(_ skill: any AgentSkill) { allSkills.append(skill) } func matchingSkills(for query: String) - [any AgentSkill] { allSkills.filter { skill in let words query.split(separator: ).map(String.init) return words.contains { skill.description.localizedCaseInsensitiveContains($0) } } } }然后改造dispatch方法用评分排序替代简单的first匹配func dispatch(query: String) async throws - SkillOutput { let candidates SkillRegistry.shared.matchingSkills(for: query) guard let best candidates.first else { throw AgentError.noSkillMatched } let input SkillInput(text: query) return try await best.execute(input: input) }这个方案最大的优点是无侵入。以后你写了一个新技能只需要在应用启动时调用一行SkillRegistry.shared.register(MyNewSkill())它就能立刻被 Agent 使用。不需要修改调度逻辑、不需要改界面、不需要重新发布核心代码。提示注册顺序会影响匹配优先级。如果你有两个技能描述了相似的关键词放在前面的会被优先选中。必要时可以为技能增加priority字段来显式排序。4. 实战中的坑与排查技巧4.1 常见问题速查表我把这个项目从开发到内测阶段遇到的问题整理成一张表每个问题都是实际踩过的坑症状原因解决方案Agent 总是选错技能技能描述太笼统关键词匹配命中率低细化description写明输入输出与边界执行技能时界面卡死大模型请求在主线程运行确保execute方法运行在后台 Task 中部分长文本被 API 拒绝超出模型上下文窗口在技能内部截断或分块处理多个技能同时执行时结果错乱共享单例存在可变状态每个技能内部使用局部变量避免写入共享存储界面显示技能不存在注册发生在dispatch之后在App的.task或init中提前注册所有技能网络请求失败且无提示错误处理太弱直接抛出为每个技能添加catch逻辑返回用户可读的错误信息第三条值得展开聊。文本摘要技能如果遇到一篇超长文章直接全量喂给模型很危险。除了截断更好的方案是分块——先把文章按段落拆开每段做小摘要再让小摘要合并成最终结果。这种方式我在一些场景里试过效果比直接截断好很多但实现复杂度也上来了。项目初期先用截断顶着等用户量上来了再迭代成多级摘要。4.2 技能执行卡顿的定位方法很多人在遇到界面卡了时第一反应是去优化模型速度但问题往往出在 SwiftUI 的状态更新上。我在开发时发现如果某个技能内部抛出一个错误而 AgentEngine 没有妥善接住isAgentRunning可能永远停在true界面一直显示转圈。定位这类问题我习惯在关键位置打印状态变化。实用的手段是加一个技能日志面板把所有调用的时间戳、技能 ID、耗时、结果全部输出到界面上。这个面板在调试期特别有用它比断点更能暴露真实调用链路的问题。例如有一次我发现某个技能虽然返回了结果但耗时比预期长了四倍追查下去是大模型 API 的max_tokens设置得太高导致每次响应都拖满时间。把参数调低后速度快了输出质量也没明显变化。4.3 并发执行与数据竞争到了后期我想让 Agent 同时执行多个技能比如用户说帮我总结这篇文章并提取关键词这时两个技能互不依赖完全可以并行。我用async let实现func dispatchParallel(query: String) async throws - [String: SkillOutput] { let skills SkillRegistry.shared.matchingSkills(for: query) var results: [String: SkillOutput] [:] await withTaskGroup(of: (String, SkillOutput).self) { group in for skill in skills { let skillCopy skill group.addTask { let output try await skillCopy.execute(input: SkillInput(text: query)) return (skillCopy.id, output) } } for await (id, output) in group { results[id] output } } return results }注意我在group.addTask里捕获了skillCopy——这是因为withTaskGroup的闭包要求值类型或者不可变的捕获避免多个任务同时访问同一个引用类型可以规避大多数数据竞争问题。还有一个容易踩的坑如果多个技能内部都在调用同一个LLMClient实例而这个实例内部维护了 session可能因为并发写入导致数据错乱。我的解决方法是把LLMClient设计成每次调用都建立独立的请求上下文不共享可变状态。5. 优化扩展与个人经验总结5.1 从规则匹配升级到模型路由关键词匹配方案在技能数量少的时候完全够用但当技能超过十个、用户问法五花八门时就会暴露局限性。我后来做了一次升级把用户的输入连同所有技能的description发给大模型让模型直接告诉我该调用哪个技能。func dispatchWithLLM(query: String) async throws - SkillOutput { let skillList SkillRegistry.shared.allSkills .map { - \($0.id): \($0.description) } .joined(separator: \n) let prompt 你是一个技能路由助手。根据用户请求从以下技能中选择最合适的一个。 技能列表 \(skillList) 用户请求\(query) 只输出技能ID不要输出其他内容。 let routeResult try await client.complete(prompt: prompt) let matched SkillRegistry.shared.allSkills.first { skill in routeResult.contains(skill.id) } guard let finalSkill matched else { throw AgentError.noSkillMatched } return try await finalSkill.execute(input: SkillInput(text: query)) }这个方案的识别准确率比关键词匹配高一个量级但代价是每次请求都要先消耗一次模型调用。实际使用中我会把路由响应设置得很短max_tokens限制在 16 以内降低延迟和成本。这里给我的体会是没有银弹每种方案都是在成本与准确率之间做权衡。5.2 缓存与离线降级方案AI 功能最怕的是网络不好或 API 超时。我加了一层简单的缓存相同输入在十分钟内直接返回历史结果。用NSCache实现内存压力大时可以自动清理对短会话场景体验提升很明显。如果网络彻底断了我给每个技能都设计了一个降级响应。比如摘要技能在无网状态下返回一条提示告诉用户当前无法提供服务并给出重试建议。这个兜底逻辑虽然简单但避免了用户看着转圈转半天然后应用闪退的糟糕体验。5.3 后续还能怎么扩展这个框架的扩展空间挺大。你可以给 Agent 增加多轮对话记忆——用SwiftData或Core Data把历史消息存下来再在技能执行前作为上下文拼进 prompt 里。也可以接入 TTS 和 STT做一个真正能语音对话的 AI 助手。还可以把技能从模型调用扩展成工具调用比如联网搜索、发邮件、操作日历——只要实现AgentSkill协议就能无缝接进来。要特别留意的是隐私问题。技能执行时哪些数据会被发送到模型服务端需要在界面上明确提示用户。我的做法是在每次调用前弹一个轻量提示并且在系统设置里提供关闭数据上报的开关。未来 App 审核中对 AI 功能的数据披露会越来越严格这个习惯建议早点养成。做这个项目最大的收获不是代码本身而是理解了边界划分在各种软件工程问题里的分量。把系统拆成界面、调度、技能、基础设施四层后往后的每一次迭代都不用推到重来。加新功能像搭积木老功能出问题也不会牵连别处。我想这才是一个好的架构该有的样子。如果你也准备在自己的项目里引入 AI 能力希望这篇记录能帮你少走几段弯路。

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

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

免费获取报价 →
↑