ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SwiftUI Agent Skill:构建智能体驱动界面的可复用技能体系

SwiftUI Agent Skill:构建智能体驱动界面的可复用技能体系

1. 从“SwiftUI-Agent-Skill”这个名字说起:它到底想解决什么

第一次看到“SwiftUI-Agent-Skill”这个组合词,我的直觉是:这大概率不是某个官方框架,而是一个把三件事揉在一起的项目命名——SwiftUI 的界面能力、Agent 的自主决策能力、以及Skill 的可复用封装。换句话说,它想做的事情,是让一个具备自主行为的智能体,能够通过一套标准化的“技能包”,去驱动 SwiftUI 构建的界面,完成一系列原本需要人工点击、输入、判断的操作。

为什么我会这么判断?因为单独看这三个词,各自都有明确的指向。SwiftUI 是苹果生态里声明式 UI 的代表,写界面快、状态驱动清晰;Agent 在近两年的技术语境里,通常指能感知环境、做出决策、执行动作的智能实体;Skill 则强调“可插拔、可复用、有明确输入输出边界”的能力单元。把三者拼在一起,最合理的解释就是:用 Skill 的形式,把 SwiftUI 界面操作或界面生成能力,封装成 Agent 可以调用的工具。

这个方向解决的真实问题很具体。过去我们做自动化测试、做界面辅助、做流程机器人,往往要写大量针对具体控件的硬编码逻辑:按钮在哪、输入框叫什么、点击顺序是什么。一旦界面改版,脚本全废。而如果让 Agent 通过“技能”去理解当前界面状态,再决定下一步动作,整个系统的鲁棒性和可维护性会完全不一样。它适合谁参考?我认为三类人最该关注:一是做 iOS/macOS 自动化工具链的开发者,二是研究 Agent 工具调用(Tool Use / Function Calling)的工程人员,三是想给自家 App 加“智能操作层”的产品技术团队。

需要提前说明的是,由于输入里项目正文、关键词、摘要都是空的,下面所有关于架构、步骤、参数的展开,都是基于“一名合格从业者在构建此类系统时最可能采用的合理方案”进行的逻辑补全。我会尽量把每个选择的理由讲透,让你能直接拿去改、拿去用。

2. 拆解核心架构:Agent、Skill、SwiftUI 三者如何握手

2.1 为什么不能让 Agent 直接操作 SwiftUI 视图树

很多人第一反应是:Agent 既然能调用工具,那我直接把 SwiftUI 的 View 树暴露给它不就行了?实测下来这条路走不通,原因有三层。

第一层是语义鸿沟。SwiftUI 的视图树是声明式的、带大量泛型与修饰符的结构,一个简单的Button背后可能嵌套了ModifiedContent、_ConditionalContent等一堆包装类型。Agent 拿到这棵树,就像让一个只会看地图的人去读电路板,信息全在但无法决策。

第二层是状态同步问题。SwiftUI 的界面由@State、@Binding、@Observable等状态源驱动,视图树只是某一时刻的投影。Agent 如果直接改视图,下一次状态刷新就会把它的修改冲掉,产生“我明明点了但没生效”的诡异现象。

第三层是安全与边界。让一个自主决策的 Agent 拥有任意修改界面的能力,等于把整个 App 的控制权交出去。一旦它的决策逻辑跑偏,用户看到的就是界面乱跳、数据错乱。

所以正确的做法是:在 Agent 和 SwiftUI 之间加一层 Skill 抽象层。Agent 只跟 Skill 打交道,Skill 负责把高层意图翻译成对 SwiftUI 状态或系统 API 的合法调用。这一层是整个项目的心脏。

2.2 Skill 的接口设计:输入、输出与副作用

一个可用的 Skill,我建议按下面的结构来定义。它不是某个官方标准,而是我在多个类似项目里总结出的、最不容易翻车的形态。

struct SkillDescriptor { let name: String // 技能名,如 "tapElement" let description: String // 给 Agent 看的自然语言说明 let parameters: [SkillParam] // 参数 schema let handler: (SkillContext) async throws -> SkillResult } struct SkillParam { let name: String let type: ParamType // string / number / bool / enum let required: Bool let description: String } struct SkillResult { let success: Bool let payload: [String: Any] // 返回给 Agent 的结构化数据 let errorMessage: String? }

这里有几个关键设计点值得展开。description 字段是给 Agent 读的,不是给人读的,所以它必须写得像“操作手册”而不是“功能简介”。比如tapElement的描述应该写成“点击当前界面上匹配指定标识符的可交互元素,若存在多个匹配则返回歧义错误”,而不是“点击元素”。Agent 的决策质量,极大程度取决于这段文字写得够不够精确。

parameters 用 schema 而不是裸字典,是为了让 Agent 在调用前就能知道自己该传什么。现代 Agent 框架大多支持 JSON Schema 形式的工具定义,把参数类型、是否必填、枚举值写清楚,能显著降低调用失败率。

handler 返回结构化结果而不是抛异常了事,是因为 Agent 需要根据结果决定下一步。比如点击失败时,它可能想先滚动屏幕再重试,而不是直接放弃。把错误信息作为正常返回值的一部分,比抛异常更利于 Agent 做恢复决策。

2.3 SwiftUI 侧的“可操作性”改造

Skill 要能操作界面,界面本身得先“可被操作”。SwiftUI 原生提供了accessibilityIdentifier,这本来是为无障碍功能设计的,但用在 Agent 场景里简直是天赐之物。

Button("提交") { submit() } .accessibilityIdentifier("submit_button") TextField("请输入", text: $input) .accessibilityIdentifier("input_field")

给每个需要被 Agent 操作的元素加上稳定的标识符,Skill 层就能通过标识符去查找元素,而不是靠坐标或文本匹配。坐标会随屏幕尺寸变,文本会随语言变,只有标识符是稳定的。

但光有标识符还不够。SwiftUI 没有提供“运行时遍历视图树并找到某个标识符对应元素”的公开 API,这是很多人卡住的地方。我的做法是维护一份注册表:在视图出现时,把标识符和对应的操作闭包注册到一个全局的SkillRegistry里。

struct SkillRegistry { static var shared = SkillRegistry() private var actions: [String: () -> Void] = [:] private var values: [String: () -> Any] = [:] mutating func registerAction(id: String, action: @escaping () -> Void) { actions[id] = action } func perform(id: String) -> Bool { guard let action = actions[id] else { return false } action() return true } }

然后在视图里这样挂载:

Button("提交") { submit() } .accessibilityIdentifier("submit_button") .onAppear { SkillRegistry.shared.registerAction(id: "submit_button") { submit() } }

这个模式的好处是:Skill 层完全不需要理解 SwiftUI 的视图树,它只需要知道“有个叫 submit_button 的动作可以执行”。视图怎么改版、层级怎么变,只要标识符和注册逻辑还在,Skill 就照常工作。这就是关注点分离带来的稳定性。

3. 把 Skill 暴露给 Agent:工具调用协议怎么定

3.1 工具描述文档的生成策略

Agent 要调用 Skill,首先得“知道有哪些 Skill”。这一步通常是把所有 SkillDescriptor 序列化成一份工具清单,喂给 Agent 的推理环节。问题在于:Skill 多了之后,这份清单会变得很长,占用大量上下文窗口,还会让 Agent 的选择变慢。

我的经验是分层暴露。把 Skill 按功能域分组,比如“界面操作类”“数据查询类”“导航类”,Agent 先选组,再选具体技能。这样每次喂给它的清单只有当前组的内容,上下文压力小很多。

另一个技巧是动态裁剪。根据当前界面状态,只暴露那些“此刻可执行”的 Skill。比如当前页面没有输入框,那inputText这个技能就没必要出现在清单里。实现方式是在生成工具清单时,先跑一遍可用性检查。

func availableSkills(for context: SkillContext) -> [SkillDescriptor] { allSkills.filter { $0.isAvailable(context) } }

isAvailable可以检查当前页面是否有对应标识符的元素、是否处于可交互状态等。这个过滤逻辑本身也是 Skill 的一部分,需要认真设计。

3.2 参数校验与歧义处理

Agent 生成的参数不总是合法的。它可能传了一个不存在的标识符,可能把数字传成了字符串,也可能漏传了必填项。Skill 层必须做严格校验,并且把错误信息写得足够具体,让 Agent 能自我纠正。

func validate(_ params: [String: Any], against schema: [SkillParam]) -> ValidationResult { for param in schema where param.required { guard params[param.name] != nil else { return .failure("缺少必填参数 \(param.name):\(param.description)") } } // 类型校验、枚举校验略 return .success }

注意错误信息里我特意带上了参数的 description。这样 Agent 看到错误后,能直接理解这个参数是干什么的,下次调用就更可能传对。这比单纯说“参数缺失”有用得多。

歧义处理是另一个高频问题。比如界面上有两个都叫“确认”的按钮,Agent 说“点击确认”,Skill 该点哪个?我的做法是返回歧义错误并附带候选列表,让 Agent 重新决策。

{ "success": false, "errorMessage": "找到 2 个匹配元素,无法确定目标", "candidates": [ {"id": "confirm_dialog", "label": "对话框确认"}, {"id": "confirm_form", "label": "表单确认"} ] }

Agent 拿到这个结果,就能结合上下文判断该点哪个,或者向用户澄清。这比 Skill 自己猜一个要好得多。

3.3 执行结果的回传格式

Skill 执行完,回传给 Agent 的数据格式直接影响后续决策质量。我建议遵循三个原则:结构化、可读、带上下文。

结构化是指用 JSON 而不是自然语言。Agent 解析 JSON 比解析散文可靠得多。可读是指字段名要自解释,别用r、s这种缩写。带上下文是指除了结果本身,还要带上“当前状态快照”,比如操作后界面变成了什么样。

{ "success": true, "action": "tapElement", "target": "submit_button", "stateAfter": { "currentScreen": "result_page", "visibleElements": ["back_button", "share_button"] } }

stateAfter这个字段特别有用。Agent 不用再单独调一次“查询当前界面”的技能,就能知道操作后的状态,减少一轮交互。在 Agent 场景里,每一轮交互都是成本,能省则省。

4. 实战搭建:从零跑通一个最小可用版本

4.1 环境准备与项目骨架

先说环境。SwiftUI 部分需要 Xcode 15 以上、iOS 17 以上的目标版本,因为要用到@Observable宏来简化状态管理。Agent 部分我建议先用一个本地的推理循环来模拟,不要一上来就接远程模型,那样调试成本太高。

项目骨架我习惯分成四个模块:

  • App 层:SwiftUI 视图和状态,负责界面呈现。
  • SkillKit 层:Skill 的定义、注册、校验、执行。
  • AgentCore 层:决策循环,负责选技能、生成参数、处理结果。
  • Bridge 层:把 SkillKit 的能力暴露给 AgentCore,把 AgentCore 的决策转成 Skill 调用。

Bridge 层是最容易被忽略但最关键的。它定义了 Agent 和 Skill 之间的契约,一旦契约稳定,两边可以独立演进。我通常把契约写成一个协议:

protocol SkillProvider { func listSkills() -> [SkillDescriptor] func execute(skillName: String, params: [String: Any]) async throws -> SkillResult }

AgentCore 只依赖这个协议,不关心 Skill 是怎么实现的。这样以后要把 Skill 换成远程服务、换成另一套实现,AgentCore 一行都不用改。

4.2 第一个 Skill:点击元素

我们从最简单的tapElement开始。它的职责是:根据标识符找到对应元素并触发其动作。

let tapElementSkill = SkillDescriptor( name: "tapElement", description: "点击当前界面上指定标识符的可交互元素。若标识符不存在返回 notFound,若匹配多个返回 ambiguous。", parameters: [ SkillParam(name: "identifier", type: .string, required: true, description: "目标元素的 accessibilityIdentifier") ], handler: { context in let id = context.params["identifier"] as? String ?? "" guard !id.isEmpty else { return SkillResult(success: false, payload: [:], errorMessage: "identifier 不能为空") } let ok = SkillRegistry.shared.perform(id: id) if ok { return SkillResult(success: true, payload: ["tapped": id], errorMessage: nil) } else { return SkillResult(success: false, payload: [:], errorMessage: "未找到标识符为 \(id) 的可点击元素") } } )

这段代码看起来简单,但有几个细节值得说。description 里明确写了两种失败模式,这是为了让 Agent 提前知道可能遇到什么,从而在决策时留好退路。handler 里先做参数校验再做执行,避免把非法参数传到注册表里。返回结果里带上 tapped 字段,方便 Agent 确认操作对象。

注册这个 Skill 只需要一行:

SkillKit.shared.register(tapElementSkill)

4.3 第二个 Skill:读取界面状态

Agent 要决策,得先知道当前界面长什么样。queryState这个 Skill 负责返回当前可见元素清单。

let queryStateSkill = SkillDescriptor( name: "queryState", description: "返回当前界面上所有可交互元素的标识符与标签,用于决策下一步操作。", parameters: [], handler: { _ in let elements = SkillRegistry.shared.visibleElements() return SkillResult(success: true, payload: ["elements": elements], errorMessage: nil) } )

visibleElements的实现依赖注册表里维护的元素元信息。我建议在注册动作时顺便把标签也存进去:

mutating func registerAction(id: String, label: String, action: @escaping () -> Void) { actions[id] = action labels[id] = label }

这样queryState返回的就是[{"id": "submit_button", "label": "提交"}]这样的结构,Agent 一看就懂。

4.4 第三个 Skill:输入文本

输入比点击复杂,因为涉及焦点管理和文本绑定。SwiftUI 里TextField的文本是通过Binding控制的,Skill 没法直接“往输入框里打字”,只能改绑定值。

我的做法是让视图在注册时提供一个 setter 闭包:

TextField("请输入", text: $input) .accessibilityIdentifier("input_field") .onAppear { SkillRegistry.shared.registerSetter(id: "input_field") { newValue in input = newValue as? String ?? "" } }

然后 Skill 这样实现:

let inputTextSkill = SkillDescriptor( name: "inputText", description: "向指定标识符的输入框写入文本。会覆盖原有内容。", parameters: [ SkillParam(name: "identifier", type: .string, required: true, description: "输入框标识符"), SkillParam(name: "text", type: .string, required: true, description: "要写入的文本") ], handler: { context in let id = context.params["identifier"] as? String ?? "" let text = context.params["text"] as? String ?? "" let ok = SkillRegistry.shared.performSet(id: id, value: text) return SkillResult(success: ok, payload: ok ? ["input": id, "length": text.count] : [:], errorMessage: ok ? nil : "输入框 \(id) 不存在或不可写") } )

注意返回里带了length而不是原文。这是出于隐私考虑——Agent 不需要知道用户输入的具体内容,只需要知道写入了多少字符。这个细节在涉及敏感信息的场景里很重要。

4.5 串起来:一个完整的决策循环

三个 Skill 就位后,Agent 的决策循环可以这样写:

func runAgentLoop(goal: String, maxSteps: Int = 10) async { var step = 0 while step < maxSteps { let state = try? await skillProvider.execute(skillName: "queryState", params: [:]) let skills = skillProvider.listSkills() let decision = await agent.decide(goal: goal, state: state, skills: skills) guard let decision = decision else { break } let result = try? await skillProvider.execute( skillName: decision.skillName, params: decision.params ) if result?.success == true && decision.isTerminal { break } step += 1 } }

这个循环里,agent.decide是核心,它接收目标、当前状态、可用技能,输出下一步动作。isTerminal标记表示这个动作完成后目标达成,可以退出循环。maxSteps是安全阀,防止 Agent 陷入死循环。

实测下来,这个最小版本已经能完成“填写表单并提交”这类任务。但离生产可用还有距离,下面几节讲的就是从能跑到好用的那些坑。

5. 踩坑实录:那些让 Agent 行为失控的细节

5.1 状态刷新时机导致的“幽灵点击”

最早跑通点击 Skill 后,我遇到一个诡异现象:Agent 报告点击成功,但界面毫无反应。排查了半天,发现是状态刷新时机的问题。

SwiftUI 的状态更新是异步的。当 Skill 调用submit()修改了@State,视图不会立刻重建,而是要等到下一个渲染周期。如果 Agent 在点击后立刻调queryState,拿到的还是旧状态,于是它以为点击没生效,又点了一次,造成重复提交。

解决办法是在 Skill 执行后主动等待一个渲染周期再返回。可以用Task { @MainActor in ... }配合await Task.yield()来让出执行权,等 SwiftUI 完成刷新。

handler: { context in let ok = SkillRegistry.shared.perform(id: id) await Task.yield() // 让出,等 SwiftUI 刷新 return SkillResult(success: ok, ...) }

这个Task.yield()看起来不起眼,但它把“幽灵点击”问题彻底解决了。类似的时机问题在导航跳转、弹窗弹出等场景都会出现,统一加一个 yield 是最省事的做法。

5.2 标识符冲突:当两个元素叫同一个名字

第二个坑是标识符冲突。开发时随手给两个按钮都写了accessibilityIdentifier("button"),结果 Agent 点击时随机命中一个,行为完全不可预测。

这个问题在代码审查阶段很难发现,因为标识符不影响正常用户操作。我的做法是在注册表里做唯一性检查,重复注册时直接报错。

mutating func registerAction(id: String, label: String, action: @escaping () -> Void) { precondition(actions[id] == nil, "标识符 \(id) 已被注册,请检查是否有重复") actions[id] = action labels[id] = label }

precondition在 Debug 构建里会直接崩溃,逼着开发者改掉重复标识符。Release 构建里它会被优化掉,不影响性能。这个策略比运行时返回错误更有效,因为它把问题暴露在开发阶段。

另外我建议给标识符定一套命名规范,比如页面名_元素类型_用途,像login_button_submit、profile_field_nickname。规范本身不强制,但团队里统一了之后,冲突概率会大幅下降。

5.3 Agent 陷入“查询-决策-查询”死循环

第三个坑最隐蔽:Agent 反复调用queryState,但从不执行实际操作。日志里看它每轮都在“思考”,但界面纹丝不动。

根因是决策提示词里没有强调“必须推进”。Agent 看到状态后,觉得信息不够,想再查一次,于是陷入循环。解决方式有两个层面。

提示词层面,明确告诉它“如果连续两次查询状态没有变化,必须执行一个改变状态的操作”。代码层面,加一个状态指纹机制:每次queryState返回时计算一个哈希,如果连续 N 次哈希相同,就强制 Agent 从可用技能里选一个非查询类的执行。

var lastStateHash: Int? var stagnantCount = 0 func checkStagnation(currentHash: Int) -> Bool { if currentHash == lastStateHash { stagnantCount += 1 } else { stagnantCount = 0 lastStateHash = currentHash } return stagnantCount >= 2 }

这个机制救过我好几次。Agent 的自主性是把双刃剑,没有这类护栏,它很容易在某个局部最优里打转。

5.4 异步操作的竞态:点击后立刻查询

第四个坑和第一个相关但更微妙。有些操作是异步的,比如点击“加载更多”后要等网络请求回来,列表才会更新。Agent 点击后立刻查询,看到列表没变,就以为失败了。

这种场景下,Skill 需要支持等待条件。我加了一个waitFor参数,让 Agent 可以指定“等到某个标识符出现为止”。

SkillParam(name: "waitFor", type: .string, required: false, description: "可选。指定一个标识符,Skill 会等待该元素出现后再返回,最多等 5 秒。")

handler 里用轮询实现:

if let waitId = context.params["waitFor"] as? String { let deadline = Date().addingTimeInterval(5) while Date() < deadline { if SkillRegistry.shared.exists(id: waitId) { break } try? await Task.sleep(nanoseconds: 100_000_000) } }

100 毫秒的轮询间隔是个经验值。太短了浪费 CPU,太长了响应迟钝。5 秒超时也是经验值,覆盖大多数网络请求场景,又不至于让 Agent 等太久。

6. 让 Skill 体系真正可维护的几个工程习惯

6.1 Skill 的版本管理与兼容性

Skill 一旦被 Agent 依赖,就不能随便改接口。改个参数名,Agent 的调用就全废了。我的做法是给每个 Skill 加版本号,接口变更时升版本而不是改原接口。

struct SkillDescriptor { let name: String let version: Int // ... }

Agent 调用时指定版本,SkillKit 负责路由到对应实现。旧版本保留一段时间,等所有 Agent 都迁移完再下线。这套机制在 Skill 数量少的时候显得多余,但一旦超过二十个,没有版本管理会非常痛苦。

6.2 用日志还原 Agent 的决策链路

调试 Agent 最头疼的是“它为什么这么做”。光看最终结果,完全猜不出中间发生了什么。我的做法是每个环节都打结构化日志:Agent 收到的状态、它生成的决策、Skill 收到的参数、Skill 返回的结果,全部按时间戳串起来。

struct AgentTrace { let timestamp: Date let step: Int let stateHash: Int let decision: String let params: [String: Any] let result: String }

把这些 trace 存下来,出问题时能完整回放。我甚至写了个小工具,把 trace 渲染成时间线视图,一眼就能看出 Agent 在哪一步跑偏了。这个投入在项目初期看似浪费,但省下的调试时间远超写它的成本。

6.3 给 Skill 写“契约测试”

Skill 的接口是 Agent 和界面之间的契约,这个契约必须稳定。我习惯给每个 Skill 写一组契约测试,覆盖正常路径、参数缺失、标识符不存在、歧义匹配等场景。

func testTapElement_NotFound() async { let result = try? await skillProvider.execute( skillName: "tapElement", params: ["identifier": "nonexistent"] ) XCTAssertEqual(result?.success, false) XCTAssertTrue(result?.errorMessage?.contains("未找到") ?? false) }

这些测试跑得快、覆盖关键路径,每次改 Skill 实现都跑一遍,能挡住大部分回归问题。更重要的是,它们把“Skill 应该怎么表现”这件事文档化了,新同学接手时看测试比看实现更快。

6.4 性能考量:Skill 调用的开销

Skill 调用本身开销不大,但 Agent 的决策循环可能很频繁。我实测过一个中等复杂度的界面,Agent 完成一个任务平均要 6 到 8 轮交互,每轮涉及一次状态查询和一次操作。如果每轮决策要调远程模型,延迟会累积到好几秒。

优化方向有两个。一是本地缓存状态,queryState的结果在短时间内可以复用,不必每次都重新遍历注册表。二是批量操作,如果 Agent 明确知道要连续做几步,可以提供一个executeSequence技能,把多个操作打包执行,减少往返。

let executeSequenceSkill = SkillDescriptor( name: "executeSequence", description: "按顺序执行多个技能,任一失败则中止并返回已执行的部分。", parameters: [ SkillParam(name: "steps", type: .array, required: true, description: "步骤数组,每项包含 skillName 和 params") ], handler: { context in // 依次执行,收集结果 } )

这个技能在表单填写场景特别有用,Agent 可以一次性提交“填姓名、填电话、点提交”三步,而不是来回三轮。

7. 这套模式还能往哪些方向延伸

跑通基础版本后,我陆续试了几个延伸方向,有些效果不错,有些还在摸索。

方向一是把 Skill 从“操作界面”扩展到“生成界面”。既然 Skill 能改状态,那它也能根据 Agent 的意图动态构建视图。比如 Agent 说“展示一个包含三个选项的对话框”,Skill 就生成对应的 SwiftUI 视图并挂载。这让 Agent 从“操作者”变成“创造者”,适合做动态表单、自适应引导这类场景。

方向二是引入 Skill 的组合与嵌套。单个 Skill 是原子操作,但真实任务往往是复合的。我试过让 Skill 之间可以互相调用,比如fillForm内部调用多次inputText。这样 Agent 面对高层任务时,只需要调一个复合技能,决策负担小很多。代价是复合技能的设计更复杂,需要仔细定义失败回滚策略。

方向三是把 Skill 的执行结果反馈给 Agent 做学习。每次任务完成后,记录哪些 Skill 组合成功率高、哪些参数容易出错,用这些数据去优化 Agent 的提示词或微调决策模型。这个方向我还在早期实验阶段,但初步看,经过几轮反馈后,Agent 的首次调用成功率有明显提升。

需要提醒的是,这些延伸都建立在基础版本稳定的前提上。我见过不少项目一上来就追求复杂组合,结果基础 Skill 的边界都没理清,Agent 行为一团糟。先把三五个原子 Skill 打磨到可靠,再考虑扩展,这是我踩过坑之后最想强调的顺序。

最后分享一个我在实际使用中养成的习惯:每次给项目加新 Skill,先问自己三个问题——它的输入输出边界清楚吗?它的失败模式有哪些、都返回明确错误了吗?它的标识符命名和现有 Skill 冲突吗?这三个问题答不上来,就先别加。Skill 体系的健康度,比数量重要得多。

RELATED READING

延伸阅读

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