SwiftUI 与 Agent 结合:声明式 UI 驱动智能体技能开发实战 1. 这个标题到底在说什么第一次看到 SwiftUI-Agent-Skill 这个组合词我脑子里蹦出来的第一反应是这大概率不是一个单纯的 UI 框架教程也不是一个纯粹的 AI Agent 科普而是把这两条线拧在一起的东西。拆开看就很清楚——SwiftUI是苹果生态里声明式 UI 的当家框架Agent指的是能自主感知、决策、调用工具去完成任务的智能体Skill则是这个智能体身上被赋予的一项项具体能力。三个词叠在一起指向的其实是一个很实在的工程问题怎么让一个跑在苹果设备上的智能体真正具备操作 SwiftUI 界面、理解界面状态、甚至动态生成和调整界面的能力。说白了这个标题背后藏着的需求是——把 SwiftUI 从人写给人看的界面变成Agent 能读、能改、能驱动的界面。这件事的价值在哪你想想现在大部分所谓的智能助手能帮你查天气、设闹钟、发消息但一旦涉及帮我把这个页面上的第三个卡片改成深色主题把这个表单的校验逻辑换成另一种规则根据我今天的日程自动重排首页布局它就抓瞎了。因为它看不懂界面也改不动界面。而 SwiftUI 的声明式特性恰好给了 Agent 一个绝佳的切入点界面本身就是状态到视图的映射只要 Agent 能理解状态、能修改状态它就能间接地操控整个界面。这个方向适合谁看我觉得有三类人值得花时间琢磨。第一类是做 iOS/macOS 独立开发的朋友手里有 SwiftUI 项目想给自己的 App 加一层智能交互让用户用自然语言就能驱动界面变化。第二类是研究端侧智能体的工程师一直在找轻量、可控、能落地的 Agent 载体SwiftUI 这种状态驱动、结构清晰的 UI 层其实是个很好的试验田。第三类是对声明式 UI 和 Agent 结合感兴趣的产品型开发者想搞清楚这条路到底能走多远、坑在哪里。不管你是哪一类下面这些内容我都会尽量讲透从设计思路到实操细节再到我踩过的坑争取让你看完就能动手试。2. 整体设计思路为什么是 SwiftUI 加 Agent 这个组合2.1 声明式 UI 天然适合被 Agent 驱动要理解这个组合为什么成立得先回到 SwiftUI 最核心的那个机制视图是状态的函数。你写一个Text(name)界面上显示什么完全取决于name这个状态变量当前的值。状态一变视图自动重算、自动刷新。这个状态到视图的单向数据流对 Agent 来说简直是天赐的接口。传统的命令式 UI 框架里界面是一棵被手动创建、手动修改的控件树。Agent 想改界面得知道那个按钮对象在哪怎么拿到它的引用改完要不要手动刷新每一步都是坑。而 SwiftUI 里Agent 根本不需要碰视图对象它只需要做一件事改状态。改完状态剩下的交给框架。这就把 Agent 的能力边界收窄到了一个非常清晰、非常可控的范围内——它操作的是数据不是视图本身。我个人的判断是这个特性决定了 SwiftUI 是移动端里最适合接 Agent 的 UI 框架之一。你甚至可以把整个 App 的状态抽象成一份可被 Agent 读写的文档Agent 的 Skill 就是一组针对这份文档的读写操作。这个心智模型一旦建立起来后面所有的设计都会顺很多。2.2 Agent 的 Skill 该怎么切分Skill这个词听起来玄乎落到工程上其实就是一组有明确输入输出、有明确副作用的函数。在 SwiftUI-Agent-Skill 这个语境下我倾向于把 Skill 分成三层来设计这样职责清晰也方便单独测试和替换。第一层是感知类 Skill负责读。比如读取当前页面的状态快照、读取某个列表的数据源、读取用户当前的筛选条件。这类 Skill 是只读的不产生副作用Agent 可以放心大胆地反复调用用来建立对当前界面的理解。第二层是操作类 Skill负责写。比如修改某个状态变量的值、往列表里插入一条数据、切换主题模式、触发一次导航跳转。这类 Skill 有副作用必须谨慎设计通常需要配合权限校验和回滚机制。第三层是组合类 Skill负责编排。它本身不直接读写状态而是把前两层 Skill 按某种业务逻辑串起来完成一个复合任务。比如把首页所有未读消息标记为已读并刷新角标这种操作就是多个原子 Skill 的组合。这么切分的好处是Agent 的推理过程可以很自然地映射到 Skill 调用上先感知、再决策、后操作。而且每一层都能单独做单元测试不用把整个 Agent 跑起来才能验证。2.3 为什么不让 Agent 直接生成 SwiftUI 代码这里有个很容易走偏的方向我得提前说清楚不要让 Agent 直接生成 SwiftUI 代码然后动态编译执行。我见过一些尝试思路是让模型输出一段 SwiftUI 代码然后用某种方式热加载。这条路在工程上几乎走不通原因有三。第一动态编译在 iOS 上有严格的限制你没法在运行时随意编译并执行新代码这跟平台的安全模型是冲突的。第二就算技术上能绕过去生成代码的质量和安全性完全不可控模型可能生成死循环、可能访问不该访问的 API、可能把界面搞崩。第三也是最关键的这条路把简单问题复杂化了。既然 SwiftUI 是状态驱动的那 Agent 改状态就够了何必去改视图代码视图代码应该是开发者写好的、经过测试的、稳定的Agent 只在状态这一层活动。所以我的设计原则很明确视图结构由开发者固定Agent 只操作状态和预定义的 Skill。这样既安全又可控而且开发者的心智负担最小。3. 核心细节拆解状态建模与 Skill 接口设计3.1 把 App 状态抽象成 Agent 可读的模型Agent 要操作状态前提是它能看懂状态。SwiftUI 里的状态散落在各种State、StateObject、EnvironmentObject里对 Agent 来说这是一盘散沙。所以第一步得把跟 Agent 相关的状态收敛到一个统一的、可序列化的模型里。我的做法是定义一个AgentObservableState协议所有需要被 Agent 感知和操作的状态类型都实现它。这个协议要求提供两个能力一个是snapshot()返回当前状态的 JSON 表示另一个是apply(patch:)接受一个描述变更的补丁并应用。JSON 这个选择是有讲究的因为大语言模型对 JSON 的理解和生成能力最强用 JSON 做 Agent 和状态之间的交换格式能最大程度降低模型出错的概率。protocol AgentObservableState: ObservableObject { func snapshot() - [String: Any] func apply(patch: [String: Any]) throws }举个具体的例子。假设有个待办清单页面它的核心状态是一个任务数组加一个筛选条件。那这个状态模型大概长这样struct TodoItem: Codable, Identifiable { let id: String var title: String var done: Bool var priority: Int } final class TodoState: AgentObservableState { Published var items: [TodoItem] [] Published var filter: String all func snapshot() - [String: Any] { return [ items: items.map { [id: $0.id, title: $0.title, done: $0.done, priority: $0.priority] }, filter: filter ] } func apply(patch: [String: Any]) throws { if let newFilter patch[filter] as? String { self.filter newFilter } if let ops patch[itemOps] as? [[String: Any]] { try applyItemOps(ops) } } }这里有个细节值得说apply方法接受的是补丁而不是完整状态。为什么因为让模型输出完整状态风险很大它可能漏掉字段、可能把没改的字段也改了。补丁模式只描述要改什么语义更清晰出错面更小。而且补丁可以做成幂等的重复应用不会出问题。3.2 Skill 接口的签名设计Skill 的接口设计直接决定了 Agent 用起来顺不顺。我总结了几条原则都是踩坑踩出来的。第一参数尽量扁平避免深层嵌套。模型对深层嵌套结构的生成准确率明显下降。如果一个 Skill 需要多个参数宁可平铺成多个顶层字段也不要搞成三层嵌套的对象。第二每个 Skill 必须有明确的描述和参数说明。这些描述最终会作为工具定义喂给模型描述写得好不好直接决定模型会不会用、用得对不对。描述里要说清楚这个 Skill 干什么、什么时候用、参数是什么含义、有没有副作用。第三返回值要结构化且信息量足够。Agent 调用完一个 Skill需要根据返回值决定下一步。如果返回值只是一句成功Agent 就没法做后续推理。返回值里应该包含操作结果、受影响的对象、当前状态的关键摘要。struct SkillDefinition { let name: String let description: String let parameters: [SkillParameter] let handler: ([String: Any]) async throws - [String: Any] } struct SkillParameter { let name: String let type: String let required: Bool let description: String }拿标记任务完成这个 Skill 举例它的定义大概是这样let markDoneSkill SkillDefinition( name: mark_task_done, description: 将指定 id 的待办任务标记为已完成。如果任务已经是完成状态不做任何改变。, parameters: [ SkillParameter(name: task_id, type: string, required: true, description: 要标记完成的任务唯一标识) ], handler: { params in guard let taskId params[task_id] as? String else { throw SkillError.missingParameter(task_id) } try todoState.markDone(id: taskId) return [success: true, task_id: taskId, remaining_undone: todoState.undoneCount] } )注意返回值里带了remaining_undone这个小设计很关键。Agent 执行完这个操作后往往需要知道还剩多少没做如果返回值里没有它就得再调一次感知 Skill 去查多一轮交互。把常用的上下文信息顺手带回来能显著减少 Agent 的往返次数。3.3 权限与安全边界Agent 能改状态就意味着它能改 App 的行为。这里必须有一道安全闸。我的做法是给每个 Skill 打上风险等级标签低风险的比如读取、切换筛选直接放行中风险的比如修改数据需要确认高风险的比如删除、清空必须显式授权。enum SkillRiskLevel { case readOnly case mutating case destructive }在 Agent 执行 Skill 之前先过一遍风险检查。对于destructive级别的操作我通常会让界面弹一个确认或者要求 Agent 在调用时带上一个明确的确认参数。这不是不信任模型而是工程上必须有的兜底。我见过太多模型理解偏了把用户数据删了的案例加一道闸成本很低收益很大。提示风险等级不要写死在 Skill 内部最好作为元数据单独维护。这样调整策略时不用改 Skill 实现改配置就行。4. 实操过程从零搭一个可跑的 Demo4.1 环境与项目结构这部分我按实际动手的顺序来讲。假设你已经有一个 SwiftUI 项目或者新建一个也行。我用的环境是较新版本的 Xcode 和 Swift语言层面用到了 async/await 和 Codable这些现在都是标配了。项目结构我建议这样组织把 Agent 相关的东西单独放一个目录跟 UI 代码解耦Project/ ├── App/ │ └── MainApp.swift ├── Agent/ │ ├── AgentCore.swift │ ├── SkillRegistry.swift │ ├── SkillDefinition.swift │ └── Skills/ │ ├── ReadSkills.swift │ └── MutateSkills.swift ├── State/ │ └── TodoState.swift └── Views/ └── TodoListView.swift这个结构的好处是Agent 那一层完全不依赖具体的视图它只依赖状态模型和 Skill 注册表。将来你想把这个 Agent 能力复用到另一个页面只要那个页面的状态实现了AgentObservableState注册对应的 Skill 就行视图层不用动。4.2 状态模型的完整实现接着上面 TodoState 的例子我把完整实现补全。这里有几个实操要点状态变更必须走apply或者专门的 mutating 方法不能从外部直接改Published属性否则 Agent 的变更和用户的变更会混在一起很难追踪。final class TodoState: AgentObservableState { Published var items: [TodoItem] [] Published var filter: String all var undoneCount: Int { items.filter { !$0.done }.count } func markDone(id: String) throws { guard let idx items.firstIndex(where: { $0.id id }) else { throw StateError.notFound(id) } items[idx].done true } func addItem(title: String, priority: Int) { let item TodoItem(id: UUID().uuidString, title: title, done: false, priority: priority) items.append(item) } func snapshot() - [String: Any] { return [ items: items.map { [id: $0.id, title: $0.title, done: $0.done, priority: $0.priority] }, filter: filter, undone_count: undoneCount ] } func apply(patch: [String: Any]) throws { if let newFilter patch[filter] as? String { self.filter newFilter } if let ops patch[itemOps] as? [[String: Any]] { for op in ops { guard let type op[type] as? String else { continue } switch type { case mark_done: if let id op[id] as? String { try markDone(id: id) } case add: if let title op[title] as? String { addItem(title: title, priority: op[priority] as? Int ?? 0) } default: break } } } } }注意snapshot()里我额外放了undone_count。这个字段严格来说是可以从 items 算出来的但把它直接放进快照Agent 就不用自己数了。这种预计算摘要在状态快照里非常值得做能省掉模型大量的推理负担也降低它算错的可能。4.3 Skill 注册与 Agent 主循环Skill 注册表负责管理所有可用的 Skill并提供给 Agent 查询和调用。它的实现不复杂核心就是一个字典加几个辅助方法。final class SkillRegistry { private var skills: [String: SkillDefinition] [:] func register(_ skill: SkillDefinition) { skills[skill.name] skill } func allDefinitions() - [[String: Any]] { return skills.values.map { skill in [ name: skill.name, description: skill.description, parameters: skill.parameters.map { [name: $0.name, type: $0.type, required: $0.required, description: $0.description] } ] } } func invoke(name: String, params: [String: Any]) async throws - [String: Any] { guard let skill skills[name] else { throw SkillError.unknownSkill(name) } return try await skill.handler(params) } }Agent 的主循环是整个系统的中枢。它的逻辑其实很朴素把当前状态快照和可用 Skill 列表一起交给模型模型返回一个或多个 Skill 调用请求Agent 依次执行把结果回填再决定是继续还是结束。final class AgentCore { let registry: SkillRegistry let state: AgentObservableState let modelClient: ModelClient init(registry: SkillRegistry, state: AgentObservableState, modelClient: ModelClient) { self.registry registry self.state state self.modelClient modelClient } func run(userInput: String, maxSteps: Int 6) async throws - String { var messages: [[String: Any]] [ [role: system, content: systemPrompt()], [role: user, content: userInput] ] for _ in 0..maxSteps { let response try await modelClient.chat(messages: messages, tools: registry.allDefinitions()) if let toolCalls response[tool_calls] as? [[String: Any]], !toolCalls.isEmpty { messages.append(response) for call in toolCalls { let name call[name] as? String ?? let params call[arguments] as? [String: Any] ?? [:] let result try await registry.invoke(name: name, params: params) messages.append([ role: tool, name: name, content: jsonString(result) ]) } continue } return response[content] as? String ?? } return 任务步骤过多已停止。 } private func systemPrompt() - String { return 你是一个运行在待办清单应用里的智能助手。 你可以通过调用工具来读取和修改应用状态。 当前状态快照会在每轮对话中提供。 修改状态前请先确认目标对象存在。 不要执行破坏性操作除非用户明确要求。 } }这个主循环有几个设计点值得展开说。maxSteps是防止 Agent 陷入死循环的保险丝我一般设 5 到 8 步太少完不成复杂任务太多容易失控。每轮都把状态快照带上是为了让模型始终基于最新状态做决策避免它拿着过期信息瞎指挥。工具调用的结果用 JSON 字符串回填保持格式统一模型解析起来也稳定。4.4 把状态快照注入对话上面主循环里我提到当前状态快照会在每轮对话中提供但代码里没体现。实际实现时我是在每轮请求前把state.snapshot()序列化后作为一条 system 或 user 消息插进去。这里有个取舍快照太大会占用大量上下文增加成本和延迟快照太小模型信息不足容易做错决策。我的经验是快照只放跟当前任务相关的字段并且做适度裁剪。比如待办清单有几百条任务全塞进去没意义可以只放未完成的、或者只放最近修改的若干条。具体裁剪策略要看业务但原则是够用就好别贪多。func injectSnapshot(into messages: inout [[String: Any]]) { let snap state.snapshot() let snapJson jsonString(snap) messages.append([ role: system, content: 当前应用状态快照\(snapJson) ]) }注意快照注入的位置很关键。放在对话最前面模型可能被后续消息冲淡放在每轮请求的最后模型对它的注意力最强。我实测下来放在最后效果更稳。5. 常见问题与排查技巧实录5.1 模型不调用 Skill直接瞎回答这是最常见的问题。用户说把第一个任务标记完成模型不调mark_task_done而是回一句好的已经帮你标记完成了实际上什么都没做。这个问题的根因通常是 Skill 描述不够清晰或者 system prompt 没有强调必须通过工具操作。我的解决办法有三步。第一在 system prompt 里明确写任何状态修改都必须通过工具调用完成不允许口头声称已完成。第二Skill 描述里写清楚触发条件比如当用户要求标记任务完成时调用此工具。第三如果模型还是不动可以在用户输入层面做一层预处理把明显的操作意图直接映射到 Skill 调用绕过模型判断。这最后一招有点作弊但在关键路径上很有效。5.2 参数传错或漏传模型调用 Skill 时参数名写错、类型不对、必填项漏传这些都很常见。排查时我一般先看模型返回的原始 tool_call确认它到底传了什么。如果是参数名问题通常是 Skill 定义里的参数名不够直观改个更明确的名字往往就好了。如果是类型问题比如该传字符串传了数字可以在 handler 里做一层宽松转换别太严格。func coerceString(_ value: Any?) - String? { if let s value as? String { return s } if let i value as? Int { return String(i) } return nil }这种容错处理看起来不优雅但实际能省掉大量调试时间。模型不是编译器别指望它每次都传对类型。5.3 状态改了但界面没刷新这个问题一般出在状态变更没有走Published属性或者变更发生在非主线程。SwiftUI 的界面刷新依赖ObservableObject的objectWillChange通知如果你直接改了一个普通属性界面当然不动。排查时先确认变更路径是否经过了Published再确认是否在主线程执行。MainActor func applyPatchSafely(_ patch: [String: Any]) async throws { try state.apply(patch: patch) }给状态变更方法加上MainActor是个好习惯能避免很多线程相关的诡异问题。5.4 常见问题速查表问题现象可能原因排查方向解决手段模型不调 Skill描述不清、prompt 未强调看原始返回改描述、加约束、预处理意图参数传错参数名不直观、类型不符看 tool_call 原文改参数名、加类型容错界面不刷新未走 Published、非主线程查变更路径统一走 apply、加 MainActorAgent 死循环任务无法完成、缺少终止条件看步数日志设 maxSteps、优化 Skill 返回值快照过大状态字段太多看请求体积裁剪快照、只放相关字段破坏性操作误执行缺少风险校验看 Skill 风险等级加确认机制、分级授权5.5 几个我踩过的坑第一个坑是过早追求通用性。一开始我想设计一套能适配所有页面的通用 Agent 框架结果抽象层太多每个页面接入都要写一堆适配代码反而更麻烦。后来我改成一个页面一套 Skill共享底层注册表和主循环落地速度快了很多。通用性这东西等你有三四个页面都接入了再回头抽象也不迟。第二个坑是忽略模型的不确定性。同样的输入模型这次调对了 Skill下次可能就调错了。所以关键路径上不能只依赖模型判断得有确定性的兜底。比如删除所有已完成任务这种操作我宁可让用户点个按钮也不完全交给模型自由发挥。第三个坑是Skill 粒度太细。一开始我把每个字段的修改都做成一个 Skill结果模型要完成一个简单任务得调七八次又慢又容易出错。后来我把常用的组合操作打包成复合 Skill比如标记完成并返回剩余数量一次调用搞定效率和准确率都上来了。粒度这个东西宁可粗一点也别太细。6. 这套方案能走多远聊到这里我想说说我对这个方向后续扩展的看法都是基于实际折腾出来的判断不是空想。最直接的一个扩展是把 Skill 从本地状态操作扩展到跨模块协作。现在 Agent 只能操作当前页面的状态但如果把 Skill 的边界放宽让它能调用其他模块的能力比如触发一次网络请求、写入本地数据库、调用系统能力那 Agent 能做的事就多了一个量级。当然每扩展一层安全边界就要重新划一次这是必须付的代价。另一个方向是让 Agent 具备一定的界面生成能力。注意不是让它生成 SwiftUI 代码而是让它从一组预定义的视图组件里挑选、组合、配置。比如你预先做好卡片、列表、图表这些组件Agent 根据数据特点决定用哪个、怎么排。这样既保留了灵活性又不会失控。我试过一个小原型让 Agent 根据任务优先级自动决定首页用列表还是看板效果还挺有意思。还有一个我觉得很有潜力的点是把用户的操作历史作为 Agent 的学习信号。用户每次手动改了界面其实都在表达一种偏好。如果 Agent 能观察到这些偏好慢慢调整自己的默认行为那它就从听话的工具变成了懂你的助手。这个方向技术上不难难的是怎么在不侵犯隐私的前提下收集和利用这些信号这个边界得把握好。最后分享一个我在实际项目里的小体会别指望 Agent 一次就把事做对要给它留出试错和纠正的空间。我现在的做法是Agent 执行完操作后界面上会有一个轻量的反馈提示用户如果觉得不对可以一键撤销。这个撤销能力不是锦上添花而是让用户敢用 Agent 的前提。毕竟一个能改界面的 Agent如果改错了还撤不回来谁敢用把撤销做好比把 Agent 做得更聪明优先级更高。