☰
SwiftUI-Agent-Skill架构:从聊天到干活,打造iOS LLM智能体
2026/10/14 8:42:21 网站建设 项目流程

我最早做 iOS 端 LLM 应用的时候,做的是个聊天框:用户发一句,模型回一句,顶多把历史消息拼在一起传上去。Demo 跑通那一刻很有成就感,但真放进业务里就发现问题了——用户要的不是“聊得好”,而是“把事办了”。你说“帮我查一下今天的天气”,模型只能回一句“我是 AI,没法访问实时数据”,这种体验放在 2025 年简直没法看。

后来我把项目拆成了 SwiftUI-Agent-Skill 这个结构,才真正把“聊天”变成了“干活”。SwiftUI 负责界面和状态呈现,Agent 是大脑和决策循环,Skill 是模型能调用的工具集合。这套架构让我在一周内把原来纯聊天 Demo 升级成了能查天气、能算时间、能查资料、能操作本地数据的助手,踩了不少坑,也沉淀出了几条值得细讲的思路。这篇文章就完整记录一下:标题里三个词为什么缺一不可,协议怎么设计最顺手,Agent 循环怎么写不卡死,以及 SwiftUI 哪些状态设计最容易翻车。

1. 一个只会聊天的 App 有多让人抓狂:Agent 的必要性

1.1 聊天不是终点,干活才是

先描述一个大多数 iOS 开发者都遇到过的场景:你接了一个大模型 API,辛辛苦苦做了消息流式渲染,用户界面也打磨得很顺滑。但测试人员输入“帮我看看这个表格里哪一行超标了”,模型根本做不到,因为它没有你的数据访问权限,也没有执行动作的能力。这时候用户只会觉得你做的 App 是个“玩具”。

我当初也是这么过来的。给 App 接入大模型之前,我想得特别单纯:模型聪明,能理解用户意图,剩下的一切都顺理成章。真实情况是,模型只是一张“嘴”,嘴再会说,没有手和脚也干不了活。要让 App 真正解决问题,必须给它一套可调用的工具。这一套工具,在这个项目里就叫 Skill。

所谓的 Agent,就是“模型 + 工具 + 决策循环”的组合:模型负责拆解用户需求、决定调哪个工具、解读工具返回的结果;工具负责访问真实世界或者执行具体操作。普通的聊天接口只输出文本,Agent 接口输出“工具调用指令”,App 再去执行并回传结果,模型基于结果继续推理。这个过程可以反复多轮,直到它认为已经能把最终答案组织出来。

1.2 标题里的三个单词其实是一张架构图

SwiftUI-Agent-Skill 这个命名不是随便拼的,它把整个项目的技术层次写得很清楚:

  • SwiftUI:负责界面层,包括对话列表、技能管理页、调用状态展示、参数配置面板。SwiftUI 声明式的特性非常适合做这种状态跳转频繁的界面,因为每个 UI 状态都能对应到一个明确的数据状态。
  • Agent:负责逻辑层,也就是对话管理、模型调用、工具调度、异常处理。它像一个调度中心,收到用户消息之后,决定要不要调工具,调哪个工具,什么时候结束。
  • Skill:负责能力层,是真正可以扩展的功能模块。每个技能封装成一个独立的单元,有自己的参数说明、校验方式、执行逻辑。新增加一个能力,不用改 Agent 主体代码,只要在技能注册表里加一条。

三层各管各的事,职责边界非常清晰。我在最初设计的时候想过直接把工具调用逻辑写在视图模型里,后来发现这会让 ViewModel 越来越膨胀,而且每个新功能都要碰业务层代码,改一个工具就可能影响别的功能。改成协议化设计之后,新增技能变成了一个“填表”的过程,心智负担小得多。

1.3 这个项目适合谁来看

如果你正好在做 iOS / macOS 上的 AI 助手类应用,或者你手上有个纯聊天应用正想加入“工具调用”能力,这篇文章应该能直接帮你避开几个大坑。就算你现在没有任何 LLM 经验,只要会用 SwiftUI 和 Async/Await,也可以照着我的设计一步步搭起来。文里涉及的模型调用我用了兼容通用接口的写法,你可以替换成自己正在用的服务,核心逻辑不变。

2. Skill 不是功能列表,而是一套协议

2.1 先定义协议,再去想功能

很多人在做这类项目时容易犯一个错误:先急着写“查天气”“查日历”这些具体功能,写到第三个功能才发现各种函数签名对不上,参数格式五花八门,Agent 引擎完全没法统一调度。我的建议是,第一步永远是定协议,功能只是协议的实现而已。

优先定义协议还有一个隐藏好处:它迫使你想清楚“技能的统一输入输出长什么样”。我在项目里把 AgentSkill 定义成下面这样:

public protocol AgentSkill: Sendable { /// 技能唯一标识,比如 weather.query var name: String { get } /// 给模型看的自然语言描述,越具体越好 var description: String { get } /// 参数定义,用于生成给模型的 JSON Schema var parameters: [SkillParameter] { get } /// 真正的执行逻辑 func execute(arguments: [String: Any]) async throws -> String }

有人会问:返回值为什么不是结构化数据,而是直接返回一个 String?我一开始也返回过 JSON,后来发现一个更省心的做法:让每个 Skill 自己负责把结果格式化成一段文本,Agent 引擎只负责透传。模型是读文本的,你返回一个 JSON 给它,它能读懂,但明显不如一句“北京今天晴,气温 5 到 15 度”来得直接。结构化数据留给技能内部消化,对外只暴露人类可读的结果,这样模型后续回答问题的压力会小很多。

参数定义我用了一个轻量的结构体:

public struct SkillParameter { public enum ValueType: String { case string, integer, number, boolean } public let name: String public let type: ValueType public let required: Bool public let description: String public init(name: String, type: ValueType, required: Bool = true, description: String) { self.name = name self.type = type self.required = required self.description = description } }

这个结构看着简单,但它解决了整个项目里最头疼的一件事:如何把 Swift 的参数定义转换成模型能理解的工具描述。后面 Agent 引擎只需要遍历一次 parameters,就能生成一份完整的工具声明。

2.2 给模型看的 JSON Schema 不必手写

大模型协议里对工具的描述通常是 JSON Schema 格式。很多教程会让你直接手写一大段嵌套字典,第一版能跑,但一旦参数多了就非常痛苦,而且容易漏掉字段。既然我们已经定义了 SkillParameter,那么 JSON Schema 完全可以自动生成。

extension SkillParameter { func toJSONSchemaField() -> [String: Any] { [ "type": type.rawValue, "description": description ] } } func buildToolSchema(from skill: any AgentSkill) -> [String: Any] { var properties: [String: Any] = [:] var required: [String] = [] for parameter in skill.parameters { properties[parameter.name] = parameter.toJSONSchemaField() if parameter.required { required.append(parameter.name) } } return [ "type": "object", "properties": properties, "required": required ] }

每次新增技能时,开发者只需要专注写 execute 内部的逻辑,工具声明、参数校验这些琐事全部自动完成。我在实际项目中还加过一个校验函数:在调用 execute 之前,先用 parameters 检查一下模型传上来的参数集是否完整、类型是否匹配。这一步极其重要,因为模型偶尔会漏参数,直接让它跑肯定会崩。

2.3 SkillRegistry:注册、发现与权限校验

有了协议和具体技能之后,还需要一个地方把它们都管起来。我这里称它为 SkillRegistry,本质上就是一个并发安全字典,加上少量元数据管理。

public final class SkillRegistry: @unchecked Sendable { private var storage: [String: any AgentSkill] = [:] private let queue = DispatchQueue(label: "skill.registry") public static let shared = SkillRegistry() public func register(_ skill: any AgentSkill) { queue.sync { storage[skill.name] = skill } } public func allSchemas() -> [[String: Any]] { queue.sync { storage.map { name, skill in [ "type": "function", "function": [ "name": name, "description": skill.description, "parameters": buildToolSchema(from: skill) ] ] } } } public func execute(name: String, arguments: [String: Any]) async throws -> String { guard let skill = queue.sync(execute: { storage[name] }) else { throw AgentError.skillNotFound(name) } return try await skill.execute(arguments: arguments) } }

注册中心最大的价值在于:Agent 引擎完全不知道项目里具体有哪些技能,它只需要在发起请求前问注册中心“列表是什么”,在拿到工具调用指令后问注册中心“这个技能在哪里”。这让我在项目后期加技能的时候,几乎没有改过引擎代码。

这里有个经验可以分享:技能命名一定要带上业务前缀,比如“weather.query”和“calendar.add”,不要用“query”这种太宽泛的名字。模型在理解多个工具时,名字本身就是语义的一部分,命名越具体,选错工具的概率越低。

3. Agent 引擎:一次对话背后的多次工具调用

3.1 Function Call 的本质:模型返回的不一定是文本

普通聊天请求里,模型返回的 content 是纯文本;但在支持工具调用的请求里,模型可能返回一个没有正文、只有 function_calls 的响应。这个响应会告诉客户端:“我需要调用 tool A,参数是 xxx。”客户端执行完工具后,要把结果作为一条新消息发回模型,模型再决定是继续调用其他工具,还是最终给出答案。

所以在 Agent 引擎里,每一次用户输入可能触发一个循环:模型决策 -> 调用技能 -> 回传结果 -> 再次请求模型 -> 循环直到结束。这个概念理解透了,引擎代码其实没那么复杂。

3.2 tool_calls 循环的 Swift 实现

我实现的时候用了 Async/Await,核心代码长这样:

func runAgent(history: [ChatMessage], skills: [ToolSpec]) async throws -> ChatMessage { var messages = history for _ in 0..<8 { let response = try await llmService.complete( messages: messages, tools: skills ) if let toolCalls = response.toolCalls, !toolCalls.isEmpty { // 1. 先把模型的请求消息加入上下文 messages.append(response.asAssistantMessage) // 2. 依次执行工具调用 for call in toolCalls { let result: String do { result = try await registry.execute( name: call.functionName, arguments: parseArguments(call.arguments) ) } catch { result = "技能执行失败:\(error.localizedDescription)" } // 3. 把执行结果作为 tool 角色消息加回上下文 messages.append(.tool(name: call.functionName, content: result)) } // 4. 继续循环,让模型基于工具结果进一步推理 continue } // 没有工具调用,说明模型准备直接回答 return ChatMessage(role: .assistant, content: response.content) } throw AgentError.maximumIterationsReached }

这里面有几个细节一定要处理好:

第一,同一条请求里可能有多个 tool_calls。有些模型一次会返回多个工具调用,比如“同时查北京和上海的天气”。我一开始图省事,只处理了第一个,结果模型在后面的对话里就会因为缺失信息而给出错误结论。所以必须遍历所有 tool_calls,全部执行完再一起回传。

第二,失败信息也要回传给模型。即使当前技能执行报错,引擎不能直接崩溃,而是要把“技能执行失败”这个结果作为文本回传。模型收到失败信息后,会尝试换一种方式解决问题,可能换成另一个参数,或者告诉用户它做不到。这种容错机制比在客户端直接抛错要友好得多。

第三,一定要有迭代上限。我项目里写的是最多 8 轮,因为模型偶尔会陷入不停调用工具的循环。没有上限,请求就会一直发,账单也会一直涨。实际业务里 3 到 5 轮基本能解决绝大多数问题,8 轮是一个安全兜底数值。

3.3 流式输出与工具调用的冲突

我的第一个版本,聊天回复流式逐字展示,体验非常丝滑。但加入工具调用之后问题来了:工具调用阶段没有文本可展示,用户看到的情况是——输入框发送后,整个界面发呆两三秒,然后突然冒出一大段答案,中间过程完全黑盒。

这其实是 Agent 类产品的通病。我在界面侧增加了“状态反馈”,把 Agent 的中间步骤展示出来,类似“正在调用天气技能”“正在计算报表”。实现上就是把引擎的进度事件通过 AsyncStream 抛给 UI 层。

engine.events = AsyncStream { continuation in // Agent 每进入一个新阶段,都向 continuation 发送一次事件 }

UI 侧接收这些事件,用一个黄条或小字提示用户“当前进行到哪一步”。这个改进对用户体验提升是巨大的,因为用户看到的不再是卡死,而是“程序确实在干活”。这也引出了 SwiftUI 层的话题——界面如何优雅地反映 Agent 的状态。

4. SwiftUI 这一层:把状态机变成看得见的界面

4.1 消息模型与 UI 状态分离

SwiftUI 里最常见的坑是:把 UI 状态散落在各种 @State 里,改一点崩一片。特别是 Agent 这种多阶段运行逻辑,消息列表、技能执行结果、正在执行的技能名、是否在等待返回……这些彼此关联但又不完全相同的状态,如果在 View 里随意声明,很快就会失控。

我最后采用了这样的分层:

  • 模型层(Model):只存放消息数据、技能配置、Agent 运行状态。例如AgentSession是一个@StateObject,内部持有[ChatMessage]、runningState、visibleSkills。
  • 视图层(View):只负责把模型层的数据渲染出来,所有交互通过调用模型层的方法触发,视图不直接持有 Agent 引擎。
final class AgentSession: ObservableObject { @Published var messages: [ChatMessage] = [] @Published var phase: AgentPhase = .idle @Published var currentSkillName: String? private let agentEngine: AgentEngine private var eventTask: Task<Void, Never>? func send(_ text: String) { let userMessage = ChatMessage.user(text) messages.append(userMessage) runAgent() } private func runAgent() { eventTask?.cancel() eventTask = Task { [weak self] in for await event in engine.events { await MainActor.run { switch event { case .toolStarted(let name): self.phase = .running self.currentSkillName = name case .toolFinished(let name, let result): self.currentSkillName = nil self.messages.append(.system("技能 \(name) 执行完毕")) case .answer(let content): self.messages.append(.assistant(content)) self.phase = .idle } } } } } }

这样做的好处是:View 的刷新逻辑非常直白,任何 UI 都只跟@Published属性绑定。比如当前正在执行哪个技能,就可以直接在消息气泡上显示名称,不用额外维护一个是否在等待的布尔值。

4.2 聊天视图的增量更新

聊天界面如果一次性把整个消息数组渲染出来,在消息变多或者长文本流式输出的时候会有性能问题。当时我试过List,发现 Agent 每插入一条状态消息就刷新整个列表,滚动位置还会跳动。后续换成了ScrollView + LazyVStack,配合scrollTo才解决了问题。

ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 12) { ForEach(session.messages) { message in MessageBubble(message: message) .id(message.id) } } .padding() } .onChange(of: session.messages.count) { _, _ in withAnimation(.easeOut(duration: 0.2)) { proxy.scrollTo(session.messages.last?.id, anchor: .bottom) } } }

这里有一个小细节:状态消息(比如“技能天气查询执行完毕”)和用户消息、AI 消息要视觉区分。我用了一个很淡的背景色块加上小字号,降低它对用户的干扰。否则 Agent 每执行一个工具,对话里就多好几条系统提示,看起来非常吵。

4.3 技能管理页与调用记录

用户侧除了聊天,还需要知道 App“会什么”。我做了一个技能管理页,列表展示所有已注册技能,包括名称、描述和参数要求。它的数据源直接读取 SkillRegistry 里的注册项,这样新增技能后界面自动出现,不用维护两套数据。

这个页面还能展开查看调用历史,包括每次调用的参数、耗时和结果。对开发者排错很有用;对用户来说,也增加了对应用的信任感。大多数 AI 应用都表现得像“不可理解的黑盒子”,技能管理页至少把执行路径摊开给用户看了。

5. 实测中反复踩的坑:解析、流式与权限

5.1 模型返回的 JSON 往往不标准

工具调用参数本质上是 JSON,但我在实测中发现,模型返回的 arguments 偶尔会出幺蛾子:多余换行、键名带引号不一致、参数值带前后空格、甚至直接把 markdown 格式夹在 JSON 里。如果直接JSONSerialization.jsonObject,很容易直接抛异常。

后来我做了一个“宽松解析”的兜底函数:把模型返回的字符串先截取出第一对花括号内的内容,再做标准解析;如果失败了,再尝试去掉所有换行和反引号做第二次解析;还失败的话,就返回一个空字典并给模型回传“参数格式无法解析,请用合法 JSON 重试”。模型看到这个提示后,通常会修正自己的输出,这比你干等着崩溃要强一百倍。

5.2 流式返回与工具调用天生不兼容

有些模型服务的流式接口会把“文本片段”和“工具调用”混在一起推给客户端,尤其当模型决定先输出两句解释再调工具时。我在一个版本里用系统原有的流式解析逻辑,只认文本,结果整个工具调用的内容被当成纯文本丢进消息,Agent 直接退出工具循环。

正确做法是:在流式解析层里区分事件类型。如果是tool_call_delta,你要做的是累积拼接参数片段,而不是当成正文;只有当整个流结束、拿到完整的函数名和参数后,才能触发技能执行。我后来在解析层加了专门的累积器,文本流和工具流分两条管道处理,才把这个问题彻底解决。

5.3 技能白名单和敏感操作提示

Skill 可以访问本地数据,也可以调用外部接口,那么权限控制就绝不能省。我项目里,SkillRegistry 只管注册,但会话层维护了一个启用的技能白名单。用户可以在设置页选择“允许哪些技能被 Agent 自动调用”,默认只开启无副作用类技能。

对于涉及写数据、删除、发送信息这类可能产生不可逆影响的技能,我还约定了一个前置规则:技能声明里带requiresConfirmation = true。Agent 引擎检测到这一类技能时,不会直接执行,而是返回“需要用户确认”的状态,UI 层弹出确认框,用户点了允许之后再真正调用。虽然多了一层交互,但这是做本地数据类工具时最基本的自我保护。

这一块我踩过一次真的教训:早期没有白名单,Agent 在幻觉状态下调用了文件删除技能,差点把本地测试数据清掉。从那以后,“权限只可收紧不可放开”就成了我的默认原则。

还有一个容易忽略的点:技能访问外部网络时一定要超时和重试并设置合理上限。我最早写的查天气技能直接发起 URLSession 请求,结果某次服务超时,Agent 卡在 tool call 循环里整整等了一分多钟,用户端体验就是彻底卡死。现在所有技能内部的网络请求都强制走统一超时配置,并且把“请求超时”作为一种正常执行结果反馈给模型。

6. 重新理解 Agent 架构:它解决的不是模型问题,而是系统组织问题

SwiftUI-Agent-Skill 这个项目做到后面,我最大的体会是:Agent 架构真正难的并不是“模型怎么选”“上下文怎么组织”,而是“如何把系统里原本散落的能力用一种统一的方式暴露给模型”。Skill 协议就是那个“统一方式”,它让新增一项能力变成一件低成本的事,也让 Agent 引擎能稳定地调度任何技能。

对正在做类似项目的读者,我的建议是先搭最小骨架:一个协议、一个注册中心、一个可跑通一轮工具调用的引擎,再加一个显示状态的 SwiftUI 页面。不要一开始就写七八个技能,先让“聊天——调用工具——回传结果——继续回答”这个闭环跑通,再慢慢往里填新能力。闭环通了之后,每加一个技能都只是在积累可用工具而已。

这个项目后续我还在继续迭代,打算把技能的失败重试机制和耗时统计分析做成可视化面板。毕竟 Agent 类应用以后会越来越复杂,如果现在不把观测能力做好,将来出了问题会非常难排查。你可以直接照着这上面的代码动手,遇到任何卡住的地方,大概率都能在文里第五部分的坑列表里找到答案。

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

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

立即咨询