SwiftUI AI Agent框架搭建:Skill协议统一管理工具调用

📅 发布时间:2026/10/21 16:06:02
SwiftUI AI Agent框架搭建:Skill协议统一管理工具调用
最近我在整理一个叫SwiftUI-Agent-Skill的Demo项目简单说就是一套在SwiftUI应用里搭建AI Agent能力的基础框架。核心思路是把Agent要执行的每一个能力抽象成“Skill”技能用一套协议统一注册、统一调度让大模型在对话中可以动态调用这些技能最终把完整的工具调用循环跑通。这个项目不是为了做一个玩具级聊天框而是想解决一个实际问题当你的AI应用不止能“说话”还要能“做事”的时候代码结构该怎么组织。如果你正在做iOS端的AI助理、智能助手类应用或者你对Agent架构感兴趣但被“工具调用”、“函数调用”这些词绕得头晕这篇内容应该能帮上忙。我会从协议设计、运行时调度、SwiftUI状态接入、实际Skill编写一路讲到调试并发问题整体都是自己踩过坑之后的经验整理不是教科书式讲解。1. 为什么要在SwiftUI里单独做一套Agent-Skill框架先说清楚Agent应用和普通聊天应用的区别。以前我们做的聊天机器人拿到用户输入之后调用大模型接口把返回文本渲染到界面上就结束了。但Agent应用多了一个关键环节大模型不仅会返回文字还可能返回一个“工具调用请求”。比如用户说“帮我查一下明天的天气”模型可能不会直接给出答案而是返回“请调用WeatherSkill参数是city某市date明天”。你的App拿到这个调用意图后需要自己去执行真正的能力然后把执行结果再送回给模型模型基于结果给出最终回复。整个过程形成一个循环。这个循环里最大的挑战不是“调用大模型”而是如何优雅地管理那些可以被模型触发的技能。我见过不少项目早期就是一堆if skillName weather的硬编码一个技能加一个分支没过多久函数体就膨胀得没法看。更麻烦的是每次新增能力都要改核心调度代码很容易把底层逻辑改坏。SwiftUI-Agent-Skill这个项目就是想从源头上解决这个扩展性问题。选择SwiftUI还有一个原因SwiftUI的声明式状态管理和Agent的状态切换天然匹配。Agent在思考、在等待模型流式返回、在执行工具、在报错——这些状态本质上就是一组可观察的枚举值。SwiftUI里用Observable或者Published直接绑定到View上界面层不需要写一堆代理回调状态一变UI自动更新开发体验比UIKit时代顺畅很多。如果你也在犹豫要不要用现成的Agent框架我的看法是大厂的Agent服务端方案可以解决云端编排但客户端侧尤其是SwiftUI应用的技能注册、本地执行、UI状态桥接目前没有一套统一的成熟方案。与其等别人给答案不如自己搭一套足够小的骨架跑通之后填业务代码就是顺手的事。2. Skill协议先行把技能边界划清楚的三个关键设计写这个项目的时候我第一个动手的不是UI也不是Agent主循环而是先把Skill协议定义出来。因为协议是整个框架的契约协议设计得不好后面所有技能都会受影响。2.1 协议的第一版两个核心字段public protocol Skill { var name: String { get } var description: String { get } var parameters: [SkillParameter] { get } var type: SkillType { get } func execute(arguments: [String: Any], context: SkillContext) async throws - SkillResult }每个字段都不是拍脑袋定的。name是技能的唯一标识大模型会根据这个名字决定要不要调用所以必须短、无歧义。description是大模型判断何时调用这个技能的依据简单说就是给模型看的“使用说明书”这行文字的质量直接决定了模型会不会正确触发技能。parameters声明了技能需要哪些参数、每个参数什么类型、是否必填相当于给模型一张参数表格。type看起来简单实际上解决了后面一个大麻烦技能是同步执行还是异步执行。有些技能比如本地计算瞬间就能出结果有些技能比如发起网络请求要几秒钟甚至更久。如果统一按异步处理响应时间不可控如果统一按同步处理又没法处理真正的异步任务。所以我分成了.sync和.async两种类型调度器可以据此决定是否要显示“技能执行中”的状态。2.2 参数定义让模型能按格式填参SkillParameter我设计成一个不可变的描述结构体public struct SkillParameter { public let name: String public let type: ParameterType // string / number / boolean / date public let required: Bool public let defaultValue: Any? public let possibleValues: [String]? public let description: String }为什么不用简单的[String: String]字典因为大模型的工具调用往往需要结构化参数。如果你只告诉模型“传一个城市名”它可能传中文、拼音、城市ID三种风格。避免这种情况的办法是在参数描述里给出possibleValues也就是枚举白名单。比如城市参数只允许[beijing, shanghai, shenzhen]模型在生成调用时就会从这些值里选出错概率大大降低。2.3 上下文隔离技能不能直接碰全局状态Skill在执行时一定会需要一些上下文信息比如当前用户ID、会话ID、用户的语言偏好、时间格式偏好。这些数据我不让技能通过全局单例去取而是通过SkillContext传进去public struct SkillContext { public let sessionID: String public let userID: String? public let locale: Locale public let timeZone: TimeZone public let tokensLimit: Int }这样做的核心原因是可测试性。写单元测试的时候我可以随时构造一个假Context不依赖App环境。如果技能里到处都是UserDefaults.standard、AppState.shared这种全局访问测试基本就是噩梦。2.4 返回结果的兼容设计SkillResult我做了三种情况成功返回文本、成功返回结构化数据、失败返回错误信息。public enum SkillResult { case text(String) case structured([String: Any]) case failure(message: String, errorCode: String?) }很多新手只设计成功路径忽略了失败路径。但你要知道大模型是很依赖错误反馈的技能执行失败后错误信息会被送回给模型模型可以据此道歉、换一种方式来问或者引导用户调整参数。比如用户问“北京明天几度”技能查不到“温度”这个参数返回failure模型收到后可能会追加追问“您是想查最高温还是最低温”这就是Agent的自我纠错能力前提是你的技能把错误信息准确传回去。3. AgentRuntime注册表、工具调用循环与会话状态协议定义好之后接下来是运行时部分。我把运行时拆成三个模块SkillRegistry注册中心、AgentSession会话、AgentRuntime调度循环。3.1 SkillRegistry用Actor保证并发安全注册中心是所有技能的集合要支持运行时动态注册、注销、查询。因为大模型的工具调用可能并发发生注册中心必须线程安全。我用actor来隔离public actor SkillRegistry { private var skills: [String: any Skill] [:] public func register(_ skill: any Skill) async { skills[skill.name] skill } public func skillsDescriptions() - [String] { skills.values.map { skill in - \(skill.name): \(skill.description) parameters: \(skill.parameters.map { $0.description }.joined(separator: ; )) } } public func resolve(name: String) - (any Skill)? { return skills[name] } }关键在于skillsDescriptions()这个方法。它把注册表里的所有技能转换成一段文字描述在每次跟大模型对话时作为System Prompt的一部分拼进去。这比手工维护一堆提示词省心太多技能列表只维护在代码里Prompt自动跟着变。3.2 工具调用循环是这个项目的灵魂AgentRuntime每收到一条用户消息就执行一轮完整的循环。伪代码逻辑是这样把用户消息追加到会话历史。把系统提示词、技能描述、历史消息一起发给大模型。判断模型返回类型如果是普通文本回复直接结束循环把文本展示给用户。如果是工具调用请求解析出技能名和参数。从注册表解析技能执行execute把执行结果拼装成一条“工具结果消息”。把这条工具结果消息追加回历史回到第2步继续调用模型。实际代码我用Swift的async/await实现循环次数做了上限保护public enum AgentRuntimeError: Error { case maxIterationReached case skillNotFound case invalidArguments } public final class AgentRuntime: unchecked Sendable { private let registry: SkillRegistry private let client: LLMClient public init(registry: SkillRegistry, client: LLMClient) { self.registry registry self.client client } public func run(session: AgentSession, userInput: String) async throws - String { var history session.history history.append(.user(userInput)) for _ in 0..session.maxToolIterations { let response try await generate(history: history, skillsPrompt: await registry.skillsDescriptions()) if case .text(let text) response.kind { session.history history return text } guard case .tools(let calls) response.kind else { continue } var toolResults: [ChatMessage] [] for call in calls { let result: SkillResult if let skill await registry.resolve(name: call.skillName) { let context SkillContext( sessionID: session.id, userID: session.userID, locale: .current, timeZone: .current ) result try await skill.execute(arguments: call.arguments, context: context) } else { result .failure(message: skill not found, errorCode: NOT_FOUND) } toolResults.append(.tool(skillName: call.skillName, result: result)) } history.append(contentsOf: toolResults) } throw AgentRuntimeError.maxIterationReached } }这里history是值类型数组每次循环末尾直接赋值回session。为什么不直接对session里的数组做修改因为Session对象可能被SwiftUI主线程持有而调度的History尽量保持值语义避免跨越角色边界时出现数据竞争。3.3 循环的最大迭代次数为什么是5我测试时发现一个现象某些不成熟的Agent框架遇到工具调用陷入死循环时会无限调用“查询当前时间”这种技能来打转。我直接把最大迭代次数设为5超过就抛错误。正常对话两三轮内必出结果如果超过5轮还出不来大概率是提示词没写清楚或者是技能描述有歧义直接让用户看到“处理超时”比无限等待更体面。3.4 AgentSession负责会话隔离public struct AgentSession: Identifiable, Sendable { public let id: UUID public let userID: String? public private(set) var history: [ChatMessage] public private(set) var createdAt: Date public let maxToolIterations: Int }每次会话独立持有自己的历史记录这样多个会话之间互不影响。这也是为以后做多会话管理、持久化打基础一个会话对应一份磁盘存储历史记录可以序列化保存。4. SwiftUI层的事件流接入从Observable到AsyncSequence框架底层跑通了接下来最难的是怎么把异步的Agent事件流接到SwiftUI视图上。这一步处理不好UI会闪烁、状态会错乱甚至直接在主线程上卡死。4.1 状态管理选型Observable优于ObservableObject这个项目用了Observable宏iOS 17相比老的ObservableObjectPublished代码更简洁也不需要手动加MainActor标注每个属性。我建议新项目直接默认用这个。import Observation import SwiftUI Observable final class AgentViewModel { enum UIState { case idle case thinking case executingSkill(skillName: String) case streaming(String) case error(String) } var state: UIState .idle var messages: [ChatMessage] [] func send(_ input: String) { messages.append(.user(input)) state .thinking Task { do { let reply try await runtime.run(session: session, userInput: input) messages.append(.assistant(reply)) state .idle } catch { state .error(error.localizedDescription) } } } }注意我在这里没有全局持有AgentRuntime的引用只是把它作为闭包捕获进Task。这是有讲究的AgentRuntime内部有循环、有网络请求如果它被ViewModel强持有可能会因为生命周期管理不当导致循环引用。每次send创建新的Task让Task持有runtime执行完自动释放。4.2 用AsyncStream做阶段事件流单纯一个statemessages是不够的因为用户希望在Agent执行工具时看到进度。这时就需要一个更细粒度的事件流。我封装了一个AgentEventStreampublic enum AgentUIEvent { case willStart case willCallSkill(name: String, arguments: [String: Any]) case skillSucceed(name: String, result: SkillResult) case skillFailed(name: String, error: String) case willCallLLM case tokenReceived(String) case completed(String?) case failed(Error) }ViewModel订阅这个事件流把事件映射为UI状态。比如willCallSkill事件到达时UI弹出一个“正在使用 计算器 技能”的小面板skillSucceed事件到达时面板显示绿色对勾如果失败变成红色叹号。这种反馈对调试和用户体验都很有帮助用户能看明白Agent当前在干什么而不是傻等一个转圈圈。4.3 事件流不直接进View而是进ViewModel有人会把AsyncStream直接放到View里用onReceive订阅这样看起来很快但会让View变得很重所有状态转换都写死在视图结构里。我把事件流限制在ViewModel层View只订阅ViewModel暴露出的state。为什么这么做因为如果View直接处理底层事件以后你想更换事件源、增加新的状态类型都得改View这违背了“UI被动展示”的原则。测试也会变难视图层的单元测试远不如逻辑层好写。4.4 .task修饰符与请求取消当用户点击“停止生成”时我们要能取消底层的大模型请求。SwiftUI里最适合绑定的生命周期是.task修饰符struct ChatInputView: View { Bindable var viewModel: AgentViewModel var body: some View { Button(发送) { Task { await viewModel.send(inputText) } } .disabled(viewModel.state .thinking) } }这里没有显式调用取消是因为每次send都会创建一个新的Task。真正需要取消的是流式读取大模型返回的任务我会在ViewModel里持有cancellationToken用户停止时置为true让底层协程停止读取。Swift的AsyncThrowingStream支持onTermination回调必须在这个回调里主动取消URLSession的请求才能做到真正断开网络连接。4.5 界面布局聊天气泡 技能状态面板实际界面我拆成三块最上面是历史消息列表中间是技能执行状态面板只在事件发生时才显示最下面是输入栏。技能状态面板是一个小卡片展示当前正在执行的Skill名称、参数、耗时、结果摘要。卡片的数据来源于agentExecutions: [SkillExecutionRecord]每次willCallSkill就添加一条记录skillSucceed或skillFailed时更新该记录这样界面就能呈现“刚刚做了什么”的完整时间线有点像调试器的可视化面板。这个小面板是开发利器。有一次测试模型死活调用不到某个技能我打开面板一看发现模型根本没有发起工具调用而是直接返回了一段文字拒绝执行。问题出在技能描述里没有告诉模型“这是本地技能请放心调用”。改完描述第二次就正常了。如果没有这个可视化面板我还得翻日志才能定位。5. 两个实战Skill与一次完整调用链走通理论讲了不少下面实际动手写两个Skill。我会挑一个简单但典型的CalculatorSkill和一个相对完整的DateTimeSkill然后展示一次完整调用链。5.1 CalculatorSkill最朴实但最能验证链路计算器技能看起来没什么技术含量但它能最快验证工具调用框架有没有跑通。这里有个细节要注意不要用系统自带的NSExpression做任意字符串解析很多运算符的行为不可控。我写了一个非常简单的表达式解析器只支持加减乘除和括号。struct CalculatorSkill: Skill { var name: String { calculator } var description: String { 适用于四则运算计算例如23*4返回计算结果。 } var parameters: [SkillParameter] { [ SkillParameter(name: expression, type: .string, required: true, defaultValue: nil, possibleValues: nil, description: 要计算的数学表达式仅支持数字、 - * / 和括号), ] } func execute(arguments: [String: Any], context: SkillContext) async throws - SkillResult { guard let expression arguments[expression] as? String else { return .failure(message: 缺少 expression 参数, errorCode: INVALID_ARGS) } // 安全检查只允许有限字符集防止注入 let allowedChars: SetCharacter [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, , -, *, /, (, ), ., ] guard expression.allSatisfy({ allowedChars.contains($0) }) else { return .failure(message: 表达式包含不支持的字符, errorCode: INVALID_EXPRESSION) } guard let value try? parseAndEvaluate(expression) else { return .failure(message: 表达式无法解析, errorCode: INVALID_EXPRESSION) } return .text(计算结果\(value)) } }这个Skill特别适合做单元测试表达式合法性校验、运算优先级、除零异常都能直接测不依赖任何外部环境。5.2 DateTimeSkill把上下文时区传进去第二个技能查当前时间看起来简单但有个坑大模型无法知道用户当前时区必须靠App端把时区信息传进Context。struct DateTimeSkill: Skill { var name: String { currentDateTime } var description: String { 获取当前日期和时间支持查询星期几。适用于现在几点今天星期几等问题。 } var parameters: [SkillParameter] { [ SkillParameter(name: timeZoneID, type: .string, required: false, defaultValue: nil, possibleValues: nil, description: 时区标识例如 Asia/Shanghai), ] } func execute(arguments: [String: Any], context: SkillContext) async throws - SkillResult { let timeZoneID arguments[timeZoneID] as? String ?? context.timeZone.identifier guard let timeZone TimeZone(identifier: timeZoneID) else { return .failure(message: 无效时区: \(timeZoneID), errorCode: INVALID_TIMEZONE) } let now Date() let formatter DateFormatter() formatter.timeZone timeZone formatter.locale Locale(identifier: zh_CN) formatter.dateFormat yyyy年MM月dd日 HH:mm:ss EEEE return .text(formatter.string(from: now)) } }这个技能展示了SkillContext的真正价值技能本身不知道当前用户是谁、在哪个时区它只接受参数和上下文。将来如果要支持多语言也不需要改这个Skill直接从Context里的locale读取对应语言格式即可。5.3 一次完整调用链跑通假设用户输入“现在几点了”模型返回{ tool_calls: [ { function: currentDateTime, arguments: {\timeZoneID\: \Asia/Shanghai\} } ] }Runtime收到后用SkillRegistry.resolve(name: currentDateTime)找到技能。解析参数创建一个带timeZone的SkillContext。执行技能返回文本结果。把结果封装成一条tool消息追加到历史。再次调用模型模型看到时间结果后生成最终回复“现在是2026年5月18日 14点23分星期二。”整个过程中UI面板依次显示思考中 → 正在调用 currentDateTime → 调用成功 → 展示最终文本。前后大约3轮循环每个环节都在面板上留下了痕迹对开发和调试极其友好。6. 并发与调试我实际踩过的四个坑和修复方式这个项目跑通只用了两天但踩坑调优用了将近一周。最有价值的部分不是代码怎么写而是哪些地方容易埋雷。6.1 坑一把Actor当成普通类在非异步上下文里等待最开始SkillRegistry使用Actor后我在一个同步的配置方法里写了let skill await registry.resolve(name: calculator)编译报错之后我改成Task { let skill await registry.resolve(name: calculator) }结果在Controller里操作这个异步返回的skill时后面跟的逻辑全都得包在Task里。这种写法会让调用链变得极难阅读还容易在Task里又嵌套Task。修复方式把对注册表的所有访问集中到AgentRuntime内部对外不暴露注册表实例。外部只调用runtime.run(...)注册表始终被Runtime托管避免调用方直接接触Actor的异步接口。6.2 坑二Skill执行跑到后台线程SwiftUI要主线程早期CalculatorSkill的execute是同步的但后续NetworkFetchSkill里的URLSession.data会切到后台线程。如果我在Skill里直接给一个MainActor的闭包发回调就会出现线程冲突。排查时的现象很典型界面偶尔出现“Partial change during structural update”崩溃或者状态栏显示异常。定位方式是加Thread.isMainThread日志发现回调确实进入了非主线程。修复方式在ViewModel接收事件回调的地方做统一MainActor隔离。ViewModel整体标注MainActor事件流的消费端同样标注主Actor这样所有UI状态更新都放到主线程底层Skill执行继续留在非主线程互不干扰。6.3 坑三参数类型丢失从模型传过来的参数是JSONJSON里{count: 3}会被解析成NSNumber而非Int。我在Skill里明明写了guard let count arguments[count] as? Int else { return ... }但无论怎么传count都是nil。排查原因是Swift的JSON解析在没有显式类型标注时会把它当成NSNumber而NSNumberas? Int 只有在类型完全匹配时才成功。修复方式写一个健壮的类型转换工具func asInt(_ value: Any?) - Int? { if let i value as? Int { return i } if let n value as? NSNumber { return n.intValue } if let s value as? String { return Int(s) } return nil }这个工具函数虽然丑但极其实用。实际对接不同大模型供应商时各家对数字参数的解析行为不一致有这一层容错能省无数时间。6.4 坑四Task取消没有传导到底层用户发送消息后立刻点停止Task被取消了但底层大模型的流式响应还在读取UI卡在“正在输入”状态好一会儿。排查后发现问题在LLMClient.streamResponse的实现没有监听Task.isCancelled。即使外层Task取消底层只是暂停了UI更新网络连接还在继续。修复方式流式读取使用AsyncThrowingStream并且把onTermination回调里取消URLSessionDataTasklet task URLSession.shared.dataTask(with: request) sequence AsyncThrowingStream { continuation in task.resume() continuation.onTermination { Sendable _ in task.cancel() } }这样用户点“停止”后网络请求真正断开底层会立刻抛出CancellationErrorUI能及时回到就绪状态。7. 从Demo走向生产还要补哪些东西现在这个框架已经能在项目里稳定跑通日常对话和工具调用。我自己用下来的体会是做Agent框架最重要的不是模型多聪明而是框架能不能让你快速定位“模型为什么没做出预期行为”。技能面板的透明化在这里帮了非常大的忙。如果你想把这套东西延伸到生产环境我会优先补这些技能权限分层。不是所有技能都能无条件调用需要给技能加requiresPermission标记高危操作发消息、删数据、支付在执行前必须弹出用户确认。技能执行历史持久化。把每次会话的技能调用记录落库既方便用户回看也方便做质量分析。技能的并发执行。当前循环是串行处理多个工具调用如果模型同时请求查天气和查日历理想情况是用withTaskGroup并行执行然后汇总结果。这一步要注意每个技能内部是否有共享资源并行前需要评估冲突。自定义技能的热加载。可以在运行时从服务端拉取新技能的描述和实现方式让App不必发版就能获得新能力。当然这涉及代码安全最好用动态库或者远程配置配合本地实现不能直接下发可执行代码。一个小技巧分享给你开发调试时在技能状态面板里给每次调用加上时间戳和耗时一眼就能看出哪个技能拖慢了整体响应。我优化网络请求类技能的时候就是靠这个数据发现有一个技能平均耗时3秒最后排查出是DNS解析太慢换了一个HttpClient实现后降到800毫秒。这种细节不落在面板上看数据纯靠感觉根本找不出来。这个项目目前还在持续迭代下一步我打算加入多Agent支持让不同Agent拥有不同的技能集合在一个App内分别处理知识问答、日程管理和智能提醒。如果你也在摸索SwiftUI里做Agent的姿势欢迎从Skill协议这个切入口自己试一遍跑通一次完整的工具调用循环之后很多概念都会一瞬间串起来。