☰
SwiftUI Agent Skill:构建可扩展的智能代理界面
2026/10/14 9:07:42 网站建设 项目流程

1. 从标题拆解这个项目的真实意图

第一次看到“SwiftUI-Agent-Skill”这个组合词,我的直觉是:这大概率不是一个单纯的 UI 组件库,也不是一个泛泛的“AI 助手”套壳项目。它把三个词拼在一起——SwiftUI、Agent、Skill——每一个都指向了不同的技术层,组合起来其实是在描述一种在苹果生态里构建具备技能调度能力的智能代理界面的工程思路。

我先把这三个词拆开说清楚,因为很多人看到这种命名会直接懵。

SwiftUI是苹果在 2019 年推出的声明式 UI 框架,用状态驱动视图,写起来比 UIKit 简洁很多。它的核心心智模型是“视图是状态的函数”,你改数据,界面自动刷新。这个框架发展到现在,在 iOS、iPadOS、macOS、watchOS、tvOS 甚至 visionOS 上都能跑,是苹果生态里做界面绕不开的东西。

Agent这个词在最近两年被用得很泛,但在工程语境下,它指的是一套能接收输入、做决策、调用工具、返回结果的循环系统。它和普通的“问答机器人”最大的区别在于:Agent 有工具调用能力和多步推理能力,它不是一问一答就结束,而是会规划、执行、观察、再调整。

Skill则是 Agent 体系里的一个关键抽象。你可以把它理解成 Agent 的“技能包”——每个 Skill 封装了一类具体能力,比如查天气、算汇率、读日历、发提醒。Agent 负责调度,Skill 负责执行。这种分层的好处是:新增能力不用改 Agent 核心逻辑,只要注册一个新 Skill 就行。

所以“SwiftUI-Agent-Skill”这个标题,我理解它要解决的核心问题是:如何在 SwiftUI 构建的界面里,把 Agent 的调度逻辑和 Skill 的执行能力串起来,做成一个可扩展、可维护的智能交互应用。

这个项目适合谁看?我认为有三类人值得花时间:一是已经在写 SwiftUI、想往智能交互方向走的 iOS 开发者;二是做 AI 应用、但界面层一直用 Web 技术、想看看原生方案长什么样的工程师;三是对 Agent 架构感兴趣、想找一个轻量落地场景练手的技术爱好者。哪怕你暂时不写 Swift,里面关于 Skill 分层、状态管理、异步调度的思路也是通用的。

接下来我会从整体设计、核心细节、实操实现、问题排查四个层面,把这个项目拆透。所有代码和参数都是基于常见工程实践给出的参考方案,你可以直接抄去改。

2. 整体架构设计与技术选型思路

2.1 为什么是 SwiftUI 而不是 UIKit

这个项目选 SwiftUI 作为界面层,我认为不是赶时髦,而是有实打实的工程理由。

Agent 类应用的界面有一个显著特点:状态变化频繁且来源多样。用户输入会改状态,Agent 的推理过程会改状态,Skill 的执行结果也会改状态。如果用 UIKit,你得手动管理一大堆 delegate、closure、NotificationCenter,状态一多就容易出现“界面和数据不同步”的经典 bug。

SwiftUI 的@State、@Observable、@Binding这套机制,天然适合这种多源状态场景。你只要把 Agent 的状态定义成一个可观察对象,界面就会自动跟着变。我实测下来,用 SwiftUI 写这类应用,状态同步相关的代码量能比 UIKit 少一半以上。

还有一个理由是跨平台。Agent 应用未来很可能要覆盖手机、平板、桌面甚至手表,SwiftUI 一套代码多端复用,这个优势在长期维护上非常明显。

2.2 Agent 层的职责边界怎么划

这是整个项目最容易做乱的地方。我的经验是:Agent 只负责“想”,不负责“做”。

具体来说,Agent 层的职责包括:接收用户输入、维护对话上下文、决定下一步调用哪个 Skill、把 Skill 返回的结果整合成自然语言回复。它不应该包含任何具体的业务逻辑,比如“怎么查天气”“怎么算汇率”,这些统统交给 Skill。

这样划分的好处是,Agent 的核心循环可以写得很稳定,几乎不用改。你新增能力时,只动 Skill 层,风险被隔离在一个个小模块里。我见过不少项目把业务逻辑塞进 Agent,结果每加一个功能就要动核心代码,改到最后没人敢碰。

2.3 Skill 的抽象设计

Skill 的设计我建议遵循一个统一协议。用 Swift 的话,可以定义一个Skill协议,要求每个技能提供三样东西:技能名称、参数描述、执行方法。

protocol Skill { var name: String { get } var description: String { get } var parameters: [SkillParameter] { get } func execute(input: [String: Any]) async throws -> SkillResult }

这个协议看起来简单,但它是整个扩展性的基石。有了它,Agent 可以用统一的方式遍历所有已注册的 Skill,把它们的描述拼成提示词,让模型知道“现在有哪些能力可用”。

参数描述这块特别关键。模型要正确调用 Skill,必须知道每个参数叫什么、什么类型、是否必填。我建议把参数定义成结构体,包含名称、类型、描述、是否必填四个字段,这样拼出来的提示词才够清晰。

2.4 数据流走向

整个项目的数据流我画不出图(这里也不用图),但可以用文字说清楚:用户输入 → Agent 接收 → Agent 结合上下文和 Skill 列表做决策 → 决策结果是一个 Skill 调用请求 → Skill 执行 → 结果回传 Agent → Agent 生成回复 → 界面更新。

这条链路里,异步是关键词。Skill 执行可能是网络请求,可能耗时几百毫秒到几秒,界面不能卡。所以 Agent 和 Skill 的执行方法都应该是async的,配合 Swift 的Task和await来写。

2.5 技术选型对照

为了让你更清楚每个选择的取舍,我整理了一张对照表:

技术点选用方案备选方案选择理由
界面框架SwiftUIUIKit状态驱动,跨平台,代码量少
状态管理@ObservableObservableObject更细粒度刷新,性能更好
并发模型async/awaitCombine可读性强,错误处理清晰
Skill 注册协议 + 数组反射扫描显式注册,可控性强
网络层URLSession第三方库原生够用,依赖少

这张表里的选择不是绝对的,但每一条我都实际用过,踩过的坑后面会讲。

3. 核心细节解析与实操要点

3.1 Agent 决策循环的实现细节

Agent 的核心是一个循环:拿输入、想一步、执行、看结果、再想,直到得出最终回复。这个循环写起来不难,但有几个细节决定成败。

第一个细节是最大步数限制。一定要设一个上限,比如 5 步或 8 步。我见过没设上限的实现,遇到模型“钻牛角尖”时会无限循环调用同一个 Skill,直接把应用卡死。设上限后,超过就强制返回当前结果并提示用户。

第二个细节是中间结果的记录。每一步的决策、调用的 Skill、返回的结果,都要记下来。这些记录一方面用于调试,另一方面可以拼进下一轮的上下文,让模型知道“我已经做过什么了”,避免重复调用。

第三个细节是错误处理。Skill 执行失败是常态,网络超时、参数错误、权限不足都可能发生。Agent 收到错误后,不应该直接崩溃,而是要把错误信息作为观察结果,让模型决定是重试、换 Skill 还是告诉用户失败。

func runAgentLoop(input: String, maxSteps: Int = 5) async -> String { var context = [Message]() context.append(Message(role: .user, content: input)) var step = 0 while step < maxSteps { let decision = await decideNextAction(context: context) if decision.isFinalAnswer { return decision.content } let result = await executeSkill(decision.skillCall) context.append(Message(role: .tool, content: result.description)) step += 1 } return "处理步骤过多,已停止。当前结果:" + context.last!.content }

这段代码是骨架,实际项目里decideNextAction和executeSkill都要做不少防护。

3.2 Skill 参数校验不能省

模型生成的参数经常不靠谱。它可能把数字写成字符串,可能漏掉必填项,可能传了不存在的参数名。如果你直接把这些参数丢给 Skill 执行,轻则报错,重则产生错误结果。

我的做法是在 Skill 执行前加一层校验。校验逻辑包括:必填项是否齐全、类型是否匹配、数值是否在合理范围。校验不通过就返回一个明确的错误描述,让 Agent 有机会重新决策。

func validate(input: [String: Any], against params: [SkillParameter]) -> String? { for param in params where param.isRequired { guard let value = input[param.name] else { return "缺少必填参数:\(param.name)" } if param.type == .number, !(value is NSNumber) { return "参数 \(param.name) 应为数字类型" } } return nil }

这层校验看起来啰嗦,但它能把大量低级错误挡在执行之前,省下很多调试时间。

3.3 提示词里怎么描述 Skill

Agent 要正确调用 Skill,全靠提示词里对 Skill 的描述。这块我踩过坑,值得细说。

描述要包含四要素:技能名、功能说明、参数列表、调用格式。功能说明要短,一句话讲清楚这个技能干什么。参数列表要列出每个参数的名称、类型、是否必填。调用格式要给出一个示例,让模型知道输出应该长什么样。

我建议用 JSON 作为调用格式,因为模型对 JSON 的生成质量普遍不错,解析也方便。示例可以这样写:

可用技能: 1. getWeather 功能:查询指定城市的天气 参数:city (字符串,必填) 调用格式:{"skill": "getWeather", "params": {"city": "北京"}}

把所有 Skill 按这个格式拼起来,放在系统提示词里。技能多了以后提示词会变长,这时候可以考虑按场景分组,只把相关的 Skill 放进当前提示词,减少干扰。

3.4 状态管理的一个关键取舍

SwiftUI 里管理 Agent 状态,我建议用一个@Observable的类,把对话历史、当前是否在处理、错误信息都放进去。

这里有个取舍:对话历史要不要持久化。如果只是 demo,放内存里就行。但如果要做成真正能用的应用,历史必须落盘,否则应用一退后台被回收,用户回来发现对话全没了,体验很差。

落盘方案我推荐用 SwiftData 或者简单的 JSON 文件。SwiftData 集成度高,但学习成本有一点;JSON 文件简单直接,适合数据量不大的场景。我自己的项目用的是 JSON 文件,每次对话更新后异步写一次,实测下来性能完全够用。

3.5 并发安全要注意

Agent 循环里涉及多个异步任务,如果用户快速连续发消息,可能出现两个循环同时跑的情况,状态就乱了。

解决办法是加一个“处理中”标志,处理中时禁用发送按钮,或者把新消息排队。我倾向于禁用发送按钮,因为排队会让用户困惑“为什么我发了没反应”。

@Observable class AgentViewModel { var isProcessing = false var messages = [Message]() func send(_ input: String) async { guard !isProcessing else { return } isProcessing = true defer { isProcessing = false } let reply = await agent.runAgentLoop(input: input) messages.append(Message(role: .assistant, content: reply)) } }

defer这个写法保证无论中间发生什么,isProcessing都会被重置,避免死锁。

4. 完整实操流程与关键环节实现

4.1 项目初始化与目录结构

新建一个 SwiftUI 项目后,我建议按职责分目录,而不是按文件类型分。目录结构大概是这样:

AgentApp/ Agent/ AgentCore.swift AgentDecision.swift PromptBuilder.swift Skills/ Skill.swift WeatherSkill.swift CalculatorSkill.swift SkillRegistry.swift Models/ Message.swift SkillResult.swift Views/ ChatView.swift MessageBubble.swift ViewModels/ AgentViewModel.swift

按职责分的好处是,你找代码时脑子里想的是“我要改 Agent 逻辑”,直接进 Agent 目录,不用在一堆 View 文件里翻。

4.2 定义核心数据模型

先把基础模型定下来,后面所有代码都依赖它们。

struct Message: Identifiable, Codable { let id: UUID let role: Role let content: String let timestamp: Date enum Role: String, Codable { case user, assistant, tool, system } } struct SkillParameter { let name: String let type: ParameterType let description: String let isRequired: Bool enum ParameterType { case string, number, boolean } } struct SkillResult { let success: Bool let content: String }

Message里加timestamp是为了排序和展示,Role里加tool是为了区分 Skill 返回的结果和助手回复。

4.3 实现 Skill 注册中心

注册中心负责管理所有 Skill,并提供按名称查找的能力。

class SkillRegistry { static let shared = SkillRegistry() private var skills: [String: Skill] = [:] func register(_ skill: Skill) { skills[skill.name] = skill } func skill(named name: String) -> Skill? { skills[name] } var allSkills: [Skill] { Array(skills.values) } }

用单例是为了方便全局访问,但如果你在意可测试性,可以改成依赖注入。我自己的项目里用的是注入方式,测试时可以塞 mock Skill 进去。

4.4 实现一个具体的 Skill

拿一个计算器 Skill 举例,它接收一个表达式,返回计算结果。

struct CalculatorSkill: Skill { let name = "calculate" let description = "计算数学表达式,支持加减乘除" let parameters = [ SkillParameter(name: "expression", type: .string, description: "数学表达式,如 1+2*3", isRequired: true) ] func execute(input: [String: Any]) async throws -> SkillResult { guard let expr = input["expression"] as? String else { return SkillResult(success: false, content: "缺少表达式参数") } let result = try evaluate(expr) return SkillResult(success: true, content: "计算结果:\(result)") } private func evaluate(_ expr: String) throws -> Double { let cleaned = expr.filter { "0123456789+-*/.() ".contains($0) } let nsExpr = NSExpression(format: cleaned) guard let value = nsExpr.expressionValue(with: nil, context: nil) as? Double else { throw SkillError.invalidExpression } return value } }

这里用NSExpression做计算,虽然它有点老,但胜在系统自带、不用引依赖。注意我做了字符过滤,只保留数字和运算符,这是基本的安全防护。

4.5 拼装提示词

提示词拼装是 Agent 能不能正确调度的关键。我把它单独抽成一个类。

struct PromptBuilder { static func buildSystemPrompt(skills: [Skill]) -> String { var prompt = """ 你是一个智能助手,可以调用以下技能来完成任务。 当需要调用技能时,只输出 JSON,格式为: {"skill": "技能名", "params": {"参数名": "参数值"}} 当不需要调用技能时,直接输出自然语言回复。 可用技能: """ for skill in skills { prompt += "\n- \(skill.name):\(skill.description)" prompt += "\n 参数:" for p in skill.parameters { prompt += "\(p.name)(\(p.type),\(p.isRequired ? "必填" : "可选")) " } } return prompt } }

提示词里的格式说明要反复强调,因为模型有时候会忘记输出纯 JSON,加一些多余的话。解析时要做容错,比如用正则把 JSON 部分抠出来。

4.6 解析模型输出

模型输出可能是纯 JSON,也可能夹在文字里。解析逻辑要能处理这两种情况。

func parseDecision(_ output: String) -> Decision { let pattern = "\\{[^{}]*\"skill\"[^{}]*\\}" if let range = output.range(of: pattern, options: .regularExpression), let data = String(output[range]).data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let skillName = json["skill"] as? String { let params = json["params"] as? [String: Any] ?? [:] return .skillCall(name: skillName, params: params) } return .finalAnswer(output) }

正则这里写得简单,实际项目里如果 JSON 嵌套深,建议用更健壮的解析方式,或者要求模型输出单层 JSON。

4.7 界面层实现

界面部分用 SwiftUI 写一个聊天列表加输入框。

struct ChatView: View { @State private var viewModel = AgentViewModel() @State private var inputText = "" var body: some View { VStack { ScrollViewReader { proxy in ScrollView { LazyVStack { ForEach(viewModel.messages) { msg in MessageBubble(message: msg) .id(msg.id) } } } .onChange(of: viewModel.messages.count) { if let last = viewModel.messages.last { proxy.scrollTo(last.id, anchor: .bottom) } } } HStack { TextField("输入消息", text: $inputText) .textFieldStyle(.roundedBorder) Button("发送") { let text = inputText inputText = "" Task { await viewModel.send(text) } } .disabled(viewModel.isProcessing || inputText.isEmpty) } .padding() } } }

ScrollViewReader用来在消息更新时自动滚到底部,这个细节不做的话,用户发完消息看不到回复,体验很差。

4.8 参数选择与性能考量

几个关键参数我给出参考值:最大循环步数设 5,单次 Skill 执行超时设 10 秒,对话历史保留最近 20 条。这些值不是拍脑袋定的,5 步能覆盖绝大多数多步任务,10 秒能容忍大部分网络请求,20 条历史在提示词长度和上下文完整性之间比较平衡。

如果 Skill 数量超过 10 个,建议做技能分组,按用户当前意图只加载相关组,否则提示词太长会拖慢模型响应,也容易让模型选错技能。

5. 常见问题与排查技巧实录

5.1 模型不调用 Skill 怎么办

这是最常见的问题。模型要么直接回答,要么输出格式不对。排查顺序是这样:先看提示词里 Skill 描述是否清晰,再看格式示例是否明确,最后看模型本身能力是否够。

我遇到过一次,Skill 描述写得太抽象,模型根本不知道什么时候该用。改成具体场景描述后,调用率立刻上来了。比如“查询天气”改成“当用户询问某地天气、气温、是否下雨时使用”,效果差别很大。

5.2 Skill 参数解析失败

模型输出的参数类型经常和预期不符。数字被写成字符串是最常见的。解决办法是在校验层做类型转换,能转就转,转不了再报错。

func coerce(_ value: Any, to type: ParameterType) -> Any? { switch type { case .number: if let n = value as? NSNumber { return n } if let s = value as? String, let d = Double(s) { return d } return nil case .string: return String(describing: value) case .boolean: if let b = value as? Bool { return b } if let s = value as? String { return s.lowercased() == "true" } return nil } }

5.3 界面卡顿

如果 Skill 执行在主线程,界面会卡。确保所有 Skill 的execute都是async的,并且耗时操作放在后台。Swift 的async/await默认会在合适的线程执行,但如果你在 Skill 里直接做同步网络请求,还是会阻塞。

5.4 常见问题速查表

问题现象可能原因排查方向解决方式
模型不调用技能描述不清检查提示词改具体场景描述
参数类型错误模型输出不规范看原始输出加类型转换层
界面卡死主线程阻塞检查 Skill 实现改异步执行
循环不停止无步数上限检查循环逻辑加最大步数
历史丢失未持久化检查存储加落盘逻辑
重复调用同一技能上下文缺失检查历史拼接把结果放进上下文

5.5 几个独家避坑技巧

第一个技巧:给 Skill 起名要短且唯一。名字太长模型容易拼错,名字重复会覆盖。我习惯用动词开头,比如getWeather、calculate、sendEmail。

第二个技巧:错误信息要写给模型看。Skill 返回的错误不要写“Error 500”,要写“查询失败,城市名可能不正确,请确认后重试”。模型看到这种描述,才知道下一步该怎么调整。

第三个技巧:日志要记全。每次决策的原始输出、解析结果、Skill 执行耗时都记下来。出问题时,这些日志能帮你快速定位是模型的问题还是代码的问题。我自己的项目里,日志会写到文件,方便事后翻查。

第四个技巧:先跑通一个 Skill 再加第二个。很多人一上来就注册五六个 Skill,结果调度乱了根本不知道哪里出问题。先用一个 Skill 把整条链路跑通,确认决策、执行、回复都正常,再逐步加。

6. 这个项目后续可以怎么扩展

把基础版本跑通之后,有几个方向值得继续做。

一是多轮对话的上下文压缩。对话长了以后,全量历史塞进提示词会超长。可以做摘要压缩,把早期对话总结成一段话,只保留最近几轮原文。

二是Skill 的权限控制。有些 Skill 涉及敏感操作,比如发消息、改数据,应该加确认步骤,让用户点一下才执行。这个在 Agent 循环里加一个“待确认”状态就能实现。

三是本地模型接入。如果不想依赖网络,可以接本地推理框架,把 Agent 的决策放在设备上跑。这样响应更快,隐私也更好,代价是模型能力可能弱一些。

四是Skill 的市场化。把 Skill 定义成可动态加载的模块,用户按需下载安装。这个架构上的改动比较大,但想象空间也大。

我自己在实际操作中的体会是,这类项目最难的不是写代码,而是把 Agent 的决策稳定性和 Skill 的健壮性同时做好。代码写起来快,调起来慢,耐心把每个边界情况处理好,最后出来的东西才真正能用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询