资讯动态

SwiftUI Agent-Skill框架:构建可扩展AI对话智能体的实战指南

发布时间:2026/10/14 10:15:35 来源:尧图企业网站定制
大概两年前我手头有个SwiftUI项目需要接入AI对话能力折腾了一圈发现Swift生态里居然没有一个顺手好用的Agent层封装要么只解决UI绑定要么只做网络请求真正把“模型调用、工具执行、技能扩展、流式渲染”串在一起的几乎没有。后来我在自己项目里沉淀了一套分层方案取名就叫“SwiftUI-Agent-Skill”用到现在已经稳定支撑了几个线上功能。这篇文章把它的设计思路、核心机制、接入步骤和踩坑经验一次性写清楚希望对正在给SwiftUI应用集成智能能力的开发者有直接帮助。1. 项目概览与设计初衷1.1 为什么需要这样一个框架先聊聊现实问题。SwiftUI本身是声明式UI框架处理界面状态、数据绑定确实舒服但AI智能体应用和传统应用有个本质区别它存在“长生命周期、多轮交互、临时工具调用”这种运行时行为这超出了SwiftUI的State驱动模型能舒服覆盖的范畴。举个例子一个待办事项应用你给用户一个输入框用户说“帮我安排明天下午三点开会并且给参会人发个提醒”这时候应用需要做几件事先理解意图、拆出“创建日程”和“发送提醒”两个动作、调用系统日历和通知权限、把结果汇总成一句自然语言回复用户。这一连串动作如果写在SwiftUI的View层代码会迅速膨胀到不可维护。所以我做这个框架的核心目标很明确让SwiftUI开发者不用关心Agent内部的调度逻辑只需要声明“我有哪些技能”然后像调用一个普通Service一样发起对话UI层自动处理流式渲染和状态刷新。1.2 整体架构与模块划分整个框架我拆成了四个模块各管一段AgentCore负责任务编排、对话状态机、上下文管理是整个框架的调度中枢。SkillKit负责技能的定义、注册、参数校验和路由。开发者新加一个能力就是实现一个Skill协议。Toolbox负责具体工具的调用也就是那些不依赖模型、纯本地的能力比如读取日历、搜索文件、执行数学计算。AgentUI提供开箱即用的SwiftUI组件包括消息列表、输入栏、流式气泡、思考过程展示等。这个分层跟MVC那种“全塞进Controller”的思路完全不同核心逻辑在于把“决策”和“执行”彻底分离。AgentCore只负责决策下一步该调用哪个技能、要不要结束对话SkillKit和Toolbox只负责执行拿到了参数就干活干完活返回结构化结果。这样替换模型、新增技能、调整UI都不会互相牵扯。我给这个架构打的比方是AgentCore是餐厅经理SkillKit是菜单Toolbox是后厨。客人点什么菜用户输入经理决定找哪个厨师路由到技能厨师用后厨的工具做菜调用Toolbox最后经理把菜端出去汇总回复。谁也别越界出了问题也一眼就能定位。2. 核心机制与关键技术选型2.1 技能注册与路由机制技能注册这块我采用的方案是协议 运行时注册表而不是继承体系。每个技能实现一个轻量协议public protocol AgentSkill: Sendable { var id: String { get } var description: String { get } var parametersSchema: String { get } // JSON Schema格式 func execute(parameters: [String: Any], context: AgentContext) async throws - String }为什么用JSON Schema来描述参数因为大模型是靠文本理解来填充参数的给它一份结构化的Schema比给它一堆自然语言说明要准得多。模型会严格按照Schema生成参数JSON框架再解析后传给execute方法。实测下来参数填充的准确率能提升两成以上尤其是涉及多字段的结构化信息时。路由机制是我反复调过的一个点。最初想得很简单把所有技能描述拼成一段系统提示词让模型选。结果技能一多模型经常选错还浪费token。后来改成两级路由先用本地关键词做粗筛比如用户输入里带“天气”就优先匹配天气技能带“提醒”就优先匹配提醒技能。粗筛结果还是多个候选时再交给模型做细粒度意图分类把候选技能缩到一个。这个方案的好处是常见请求直接走本地规则速度快还省钱模棱两可的请求才动用模型判断准确率和成本达到一个平衡。实测下来路由准确率从最初的八成左右提升到接近百分百每次对话的平均token消耗也降了大概三成。2.2 流式输出与Swift并发适配SwiftUI原生的Text组件对增量更新其实没有做特殊优化直接在主线程逐字更新字多了一样会卡。这个框架里我做了两层优化第一层是数据流通道。底层模型返回的内容封成AsyncThrowingStreamString, Error通过for await逐段接收。这样无论是云端流式接口还是本地模型的输出都能统一适配。第二层是UI更新隔离。所有流式数据先在后台解析整理累积到一定量或者遇到语义边界比如换行、句号时再一次性切回主线程刷新。不是每来一个token都刷新一次而是每积累几十个token刷新一次。用户感知上是流畅的逐字蹦出实际UI刷新次数少了将近一个数量级。private func streamResponse() async { do { for try await chunk in agent.streamReply(to: inputText) { let newText currentText chunk await MainActor.run { messages[messages.count - 1].content newText } } } catch { await MainActor.run { errorMessage error.localizedDescription } } }这里有个容易被忽视的坑任何对Published属性的修改都要确保在MainActor上执行。如果你在后台Task里直接改轻则UI不刷新重则触发运行时警告甚至崩溃。所以每次更新前都包一层await MainActor.run看似啰嗦但这是Swift并发迁移后的铁律。2.3 上下文管理与记忆裁剪上下文管理是Agent应用最容易被低估的模块。我之前见过不少项目跑着跑着对话变慢、效果变差排查到最后都是上下文无脑累积把模型输入撑爆了。这个框架的上下文管理策略是“三区隔断”系统提示区固定内容包含角色设定、技能清单、全局规则不参与裁剪。对话轮次区用户和AI的交互历史按滑动窗口保留最近的N轮。临时工具结果区工具调用的返回值用完即弃只保留当次的摘要。滑动窗口的大小不是拍脑袋定的而是根据token预算反推。比如模型上下文上限是8000 token系统提示占了1200技能描述占了600那就剩6200给对话和工具结果。按每条对话平均150 token估算保留35轮比较安全。超过这个数量后框架会触发摘要压缩把最早的对话递归总结成一段摘要释放空间。func trimContextIfNeeded() { let budget maxTokens - systemTokens - skillTokens while estimateTokens(contextMessages) budget { let oldest contextMessages.removeFirst(2) // 取出最早的一问一答 let summary try? summarizer.summarize(oldest) contextMessages.insert(summary, at: 0) } }这个方案的巧妙之处在于摘要本身也占用token所以要有“摘要的摘要”。我做过一个极端测试连续对话200轮上下文始终稳定在预算区间内模型对早期信息的记忆虽然会淡化但关键结论还能通过摘要保留。3. 从零接入实操步骤与代码实现3.1 环境准备与依赖集成先说明一下环境要求依赖最低版本说明iOS16.0需要支持NavigationStack和AsyncStreamSwift5.9需要if case和宏的完整支持Xcode15.0编译器和调试器兼容模型API任意兼容Chat接口的服务框架层已做抽象集成方式我推荐直接用SPMSwift Package Manager。在Xcode里选File Add Package Dependencies填仓库地址版本选from: 1.0.0即可。不推荐手动拖源码进工程因为框架内部有多个target手动集成很容易漏掉资源文件。3.2 定义一个自定义技能假设我要做一个“查天气”的技能第一步是声明技能元信息第二步是实现在地逻辑。天气数据我用的是一个公开的天气接口但具体哪个不重要重点是看整个技能的骨架public struct WeatherSkill: AgentSkill { public var id: String { weather_query } public var description: String { 查询指定城市的当前天气包括温度、湿度、风力等信息 } public var parametersSchema: String { { type: object, properties: { city: {type: string, description: 城市名称如北京}, date: {type: string, description: 日期格式YYYY-MM-DD默认为今天} }, required: [city] } } public func execute(parameters: [String: Any], context: AgentContext) async throws - String { guard let city parameters[city] as? String else { throw AgentError.invalidParameter(city) } let date parameters[date] as? String ?? currentDateString() let result try await WeatherAPI.fetch(city: city, date: date) return \(city) \(date) 天气\(result.condition)气温\(result.temperature)℃湿度\(result.humidity)% } }注册技能就更简单了let agent try Agent(configuration: .default) try agent.register(skill: WeatherSkill()) try agent.register(skill: ReminderSkill())整个过程中我踩过最深的坑是参数类型匹配。模型按Schema生成JSON时数字可能生成整数也可能是浮点数遵守Sendable协议时字典的Value类型是Any取出后如果能明确转型就尽早转不要等到用的时候再转。我一开始图省事在execute内部用了as? Int结果连续几天下发的日期参数都是字符串排查了很久才发现是模型把日期数字当字符串输出了。后来统一在框架层做了一次“宽松转型”能转Int就转Int不行再按字符串解析问题彻底解决。3.3 在SwiftUI视图中装配Agent框架的UI层组件用起来很像SwiftUI自带组件。核心就是一个AgentChatView绑定一个AgentViewModel配置好初始技能剩下的消息列表、输入框、流式渲染它都帮你处理。struct ContentView: View { StateObject private var viewModel: AgentViewModel init() { let agent try! Agent(configuration: .default) try! agent.register(skill: WeatherSkill()) try! agent.register(skill: ReminderSkill()) _viewModel StateObject(wrappedValue: AgentViewModel(agent: agent)) } var body: some View { NavigationStack { AgentChatView(viewModel: viewModel) .navigationTitle(智能助手) } } }如果你想自定义UI框架也允许只拿AgentCore自己写消息列表。我们核心的对话模型是MainActor public final class AgentViewModel: ObservableObject { Published public var messages: [ChatMessage] Published public var isThinking: Bool Published public var errorMessage: String? public func send(_ text: String) async { ... } }有个小细节值得注意AgentViewModel声明了MainActor这会让整个类的方法默认在主线程执行跟SwiftUI的更新机制天然对齐。但副作用是如果你的技能内部有耗时操作也会被卡在主线程。解决办法是技能内部自己开独立的Task执行耗时逻辑只把结果返回到主线程。3.4 一个完整可跑的Demo串联最后用一个“智能待办助手”把全部串起来。这个Demo支持三个技能添加待办、查待办、清空已完成。struct TodoItemSkill: AgentSkill { var id: String { todo_add } var description: String { 添加一条待办事项 } var parametersSchema: String { ... } func execute(parameters: [String: Any], context: AgentContext) async throws - String { let title parameters[title] as? String ?? 未命名事项 let dueDate parameters[dueDate] as? String ?? 无截止日期 try await store.add(title: title, dueDate: dueDate) return 已添加待办\(title)截止时间\(dueDate) } }用户说“明天上午十点提醒我交周报”先由AgentCore判断出意图是添加待办把“明天上午十点”解析成dueDate填充进参数再调用技能的execute方法。整个过程除了界面输入开发者只需要关心技能的execute怎么写。我本地测试过从输入到返回结果一条消息在普通网络上耗时大约800毫秒到1.5秒其中模型推理占了绝大多数框架本身的开销可以忽略不计。4. 实战场景与技能扩展经验4.1 场景一智能表单填写助手我在一个内部工具里做过表单自动填写。用户说“创建一个叫双11促销的活动预算三万元负责人张三下周一上线”框架需要把这句话映射到表单的十几个字段。这种场景对技能设计的要求是参数Schema要跟表单字段一一对应并且字段描述要写清楚格式约束。我当时在Schema里给每个字段都加了description和enum取值模型填充的准确率就能保持在九成以上。剩下的一成错误大部分是日期格式不统一。后来我在Schema里明确要求“日期必须使用ISO 8601格式”准确率立刻上去了。这个经验可以概括为**模型是一张白纸你不给它精确到字节的约束它就会按自己的理解自由发挥。**凡是你期望它输出的内容必须在Schema或者系统提示词里有明确的“标准答案”式说明。4.2 场景二会议纪要结构化输出另一个场景是会议纪要。语音转文字后把全文交给模型要求输出“决议事项、待办任务、风险点”三个板块。这个场景我不走技能的路由而是定义一个专用技能输入是原始文本执行过程是调用模型做摘要提取再返回结构化结果。public struct MeetingMinutesSkill: AgentSkill { public var id: String { meeting_minutes } public var description: String { 将会议记录整理为结构化纪要 } public var parametersSchema: String { { type: object, properties: { rawText: { type: string, description: 会议原始文本可以是语音转写的完整内容 } }, required: [rawText] } } public func execute(parameters: [String: Any], context: AgentContext) async throws - String { let rawText parameters[rawText] as? String ?? let prompt 请将以下会议记录整理为结构化纪要包含三个部分 1. 决议事项明确结论和决策 2. 待办任务负责人截止时间 3. 风险点潜在问题和应对措施 会议记录 \(rawText.prefix(6000)) let response try await context.llm.complete(prompt: prompt) return response } }这里有个独特的设计点技能内部的execute并不一定只调用本地代码它也可以通过context.llm再次请求模型。这解决了“Agent主流程已经调过一次模型技能内部还需要模型理解”的重复劳动问题。框架把LLM客户端挂在AgentContext上任何技能都能直接取用。4.3 扩展新技能时最容易踩的坑技能扩展看着简单实际踩坑不少。我总结四个高频问题技能命名冲突两个技能id一样时后注册的会把先注册的覆盖。我一开始没做重名校验线上出过一次事故。现在框架在注册时会主动抛错。描述写得像论文技能description太长模型容易看花眼。合理长度是30-60字像个电梯演讲说清楚“能做什么、不能做什么”。参数Schema缺少required字段不声明必填项模型就会偷懒能省则省。我吃的亏是连续三个技能都漏了requiredAI回复经常是“请提供更多信息”体验很差。execute里不抛错技能执行失败时必须抛出带有明确信息的错误让AgentCore能感知到并转达给用户。如果什么都吞掉用户只会看到一句莫名其妙的“抱歉我无法处理”。5. 高频问题排查与性能调优5.1 流式输出丢字或卡顿症状对话回复到一半突然没了或者UI卡顿明显。排查思路先看日志里流是否断掉。流式接口如果网络中断不会自动重连框架虽然内置了简单的重试机制但只重试两次。如果是UI卡顿检查你的View是否因为状态更新做了大量重算尤其是List里每个气泡绑定了深层ObservedObject的情况。解法列表项尽量拆成独立View让每一行的body只依赖自己那一条消息的字段不要把整个messages数组传进去。流式更新时只更新变化的那一行。5.2 技能路由命中不准症状用户说“帮我设个闹钟”结果触发了“提醒”技能而不是“闹钟”技能。原因本地关键词粗筛时“提醒”和“闹钟”词面上完全不同但语义上很像粗筛阶段漏掉了。解法给技能增加别名机制。在description之外增加一个keywords数组把同义词、近义词、常见说法都放进去。“提醒”可以加[闹钟, 定时, 几点叫我]“天气”可以加[气温, 下雨, 出门要不要带伞]。粗筛阶段是关键词匹配别名越多命中越准。5.3 上下文膨胀导致响应变慢症状对话到后面响应越来越慢而且回复质量下降。原因上下文输入太长模型处理时间线性增长而且早期信息对新回复的干扰也会变大。解法参考我在2.3里写的裁剪策略。这里再说一个细节裁剪时机不能等超出预算才裁而是在每轮回复结束后就检查一次顺便把摘要存入持久化存储。这样即使App杀掉重开长对话的记忆也能恢复。5.4 冷启动与模型切换症状App每次启动首次对话都很慢切换到别的模型后行为不一致。原因Agent初始化时要加载技能表、构建系统提示词、建立上下文这些都是一次性开销切换模型时Prompt风格不同输出格式也会有差异。解法框架支持AgentConfiguration预置把初始化工作放到启动阶段在AppDelegate或init里提前创建单例。切换模型时建议锁定temperature和top_p参数避免同一句话在不同模型下输出格式天差地别。我的实操经验是每次切换模型后先用三条固定测试语句跑一遍全技能链路确认输出格式正常再灰度给用户。6. 写在最后的经验体会用这套框架做了几个线上功能之后我最大的感受是SwiftUI开发Agent应用真正的难点不在UI也不在模型调用而在于把“不确定的模型行为”约束到“确定的代码流程”里。Agent-Skill这个框架之所以好用就是因为它把所有不确定性收敛到了技能定义这一层你只需要保证每个技能的输入输出是可预期的整个Agent的行为就是可预期的。最后分享一个小技巧。如果你准备在自己项目里用这套思路建议先只接入一个技能把这个技能从注册、路由、执行、返回的完整链路跑通再逐步增加。不要一上来就注册七八个技能否则遇到问题根本不知道是哪个环节出的错。技能越多路由策略和Token预算的调整就越复杂先小步快跑、验证机制再铺开规模这是最稳的路径。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑