1. 项目概述:从标题拆解 SwiftUI-Agent-Skill 到底在做什么
1.1 项目标题拆解:Agent、Skill 与 SwiftUI 三者的关系
看到“SwiftUI-Agent-Skill”这个标题,可能很多人第一反应是“又是一个 AI 套壳 Demo”,但实际拆解下来会发现,这个项目真正想解决的是一个非常具体的问题:在苹果生态内,如何用 SwiftUI 优雅地承载一个具备多技能(Skill)调用能力的智能体(Agent)界面,同时保持代码可维护、可扩展、可复用。
拆开看三个关键词:
- SwiftUI:苹果原生声明式 UI 框架,负责所有界面展示、交互、状态绑定。选它而不是 UIKit,原因不只是“新”,更重要的是 SwiftUI 的状态驱动特性天然适合聊天/流式输出这类高频刷新场景。
- Agent:这里的 Agent 不是某个具体产品,而是一个“大脑”角色,负责接收用户输入、编排对话逻辑、决定调用哪个工具或技能、管理上下文。它不关心 UI 长什么样,只关心“怎么思考”。
- Skill:这是最容易忽略但最核心的部分。Skill 是一组可插拔的能力模块,比如天气查询、计算器、备忘录操作、网络搜索、文件处理等等。Agent 本身不实现具体能力,而是通过“注册”的 Skill 去执行具体任务。这种设计把“大脑”和“手脚”彻底解耦。
所以这个项目本质上是一个架构工程,而不是一个“能聊天的 App”。它最大的价值在于:如果你手里有多个 App 需要接入 AI 能力,或者你在做一款需要不断扩展功能的智能助手,用这套分层方案,每加一个新技能只需要写一个 Skill 实现类,注册进去就完事了,UI 层和 Agent 层完全不用动。
1.2 这套方案适合谁、能解决什么问题
我个人的判断是,这个项目最适合三类人:
- 正在做 iOS 智能助手类产品的开发者:如果你的需求不是简单的“接个大模型 API 输出文本”,而是想让 AI 真正操作 App 内的功能(比如帮你建个备忘录、设置个提醒、发个请求),Skill 注册表这套设计会非常省心。
- 团队中负责客户端架构的人:SwiftUI 状态管理和并发处理稍有不慎就会变成一团乱麻,这里的状态机设计思路可以当做一个参考模板。
- 想从“调 API”升级到“做 Agent”的独立开发者:很多人接了一个 LLM API 就在页面上打一句“你已经接入了 AI”,但真正的 Agent 是要有工具调用、有流式交互、有状态管理的。这篇文章里的完整流程,基本就是你从零到一需要的全部图纸。
同时,这份实践包含我从立项、编码到上线过程中踩过的坑,涉及的代码和方案都是真实项目中跑过的,不是教科书示例,都有实测结果支撑。
1.3 技术栈与运行环境
为了后有可复现性,先把基础环境列清楚(基于常见实践补充):
- 开发工具:Xcode 15 及以上(建议 16,对 @Observable 支持更友好)
- 语言版本:Swift 5.9+ / Swift 6 兼容模式
- 最低系统版本:iOS 17.0(因为大量使用 @Observable 宏和 SwiftUI 新特性)
- 模型服务:任何兼容 OpenAI 格式的 API 服务均可,文内示例用通用 HTTPS 请求演示,不依赖特定厂商
- 附加库:仅使用 SwiftUI 原生框架和 Foundation,不引入第三方依赖,方便直接跑通
这个组合在 2024 年之后的主流光环境上都能直接跑,不需要额外权限配置。
2. 项目整体设计与分层思路
2.1 为什么选择“三明治”架构而非大一统式编码
在开始写任何 UI 之前,我先把代码结构定了。这也是我强烈建议的第一步:不要从聊天界面入手,而是先画清楚“数据流”和“调用关系”。
我用的是一个非常简单的“三明治”结构:
| 层级 | 职责 | 相关组件 |
|---|---|---|
| 视图层(View Layer) | 展示聊天内容,收集用户输入,渲染流式回复 | ChatView、MessageBubble、InputBar |
| 状态与逻辑层(State & Logic Layer) | 持有对话状态、管理消息数组、驱动 Agent 执行 | ConversationState、AgentRuntime |
| 能力层(Skill Layer) | 提供可插拔的具体工具能力 | SkillProtocol、SkillRegistry、各 Skill 实现 |
这个分层的核心原则是:View 不直接调用 Skill,View 只和 State 打交道;Agent 不直接渲染 UI,Agent 只产出状态变化。这样做的原因很朴素——聊天界面最烦人的就是“状态混乱”,消息顺序错乱、UI 和逻辑耦合过深导致改一个按钮都要动一堆代码。
举个具体的例子:如果按照大而全的方式,把大模型调用直接写在 Button 的 action 里,初期很爽,但一旦你要支持流式输出、要支持中途停止、要支持多轮工具调用、要保存历史记录,麻烦就来了。而分层之后,View 只是“状态投影”,逻辑全部收敛在 State 层和 AgentRuntime 里,扩展性和调试体验完全不在一个数量级。
2.2 Agent 与 UI 的边界:UI 只做状态投影
这里有一个我花了不少时间才想明白的点:Agent 不应该知道 UI 的存在。
具体体现为:Agent 层只接收一个“输入请求”的纯数据对象(包含用户文本、历史消息上下文、可用的技能注册列表),然后返回一个“结果数据流”(可能是增量文本,也可能是“我要调用某个技能”的事件)。View 拿到这些数据后自行决定怎么展示,比如流式文本用 .text 更新,工具调用用一个特殊的卡片视图展示。
这种做法和传统的 UIKit 时代最大的区别在于“状态驱动”。SwiftUI 本身就是声明式的,如果你把 Agent 的输出直接拿来做本地 state 修改,界面会自然跟随刷新,而不需要手动 reloadData 之类的操作。
在具体设计上,我让 ConversationState 成为整个界面的唯一可信数据源(Single Source of Truth),所有消息、正在输入的状态、错误信息都放在里面。View 只负责“读取状态并渲染”,用户输入则转成 Intent(意图)发给 AgentRuntime,由它决定下一步动作。
2.3 数据流示意图(文字版说明)
因为不方便画图,这里用文字理一遍核心数据流:
- 用户在输入框输入文本,点击发送。
- View 层把这个动作转成
MessageInputContent,追加到 ConversationState 的消息数组中(type = .user)。 - ConversationState 把完整上下文(历史消息 + 最新的用户输入)发给 AgentRuntime。
- AgentRuntime 进入“正在思考”状态,并调用 LLM 接口,配置里携带当前注册的所有 Skill 描述信息。
- LLM 返回流式数据:可能是纯文本增量,也可能包含一个
tool_calls意图(例如天气技能参数)。 - AgentRuntime 解析返回数据:
- 如果是纯文本,把增量文本发回 ConversationState,在消息数组中更新 assistant 消息的 content。
- 如果是工具调用,先暂停追加文本,把“工具调用中”状态发到 UI,把解析好的参数投递给 SkillRegistry 找到对应的 Skill。
- Skill 执行完成后返回结果(纯文本或结构化数据)。
- AgentRuntime 把工具结果再次发送给 LLM(作为上下文的一部分),循环直到 LLM 不再请求工具调用、给出最终文本为止。
- 最终文本追加到 ConversationState 中,完成整个交互闭环。
这套流程的关键在于第 6 步和第 8 步之间的“循环”。没有这个循环,你的 Agent 就是一个“只动嘴的哑巴”,无法真正调用技能。很多初步实现失败了,都是因为没有实现第 8 步——工具调用的结果没有回到模型那边,模型就不知道技能执行得怎么样。
2.4 选型思考:@Observable 还是 ObservableObject
SwiftUI 状态管理,在 iOS 17 之后我用了一个关键选型:全面使用@Observable宏替换ObservableObject+@Published的组合。
原因有两个:
@Observable是宏展开后自动生成跟踪逻辑,代码更简洁,少写大量模版。这在状态字段增多(消息数组、加载状态、错误对象、技能执行状态)时体验差异非常明显。@Observable配合@Bindable做双向绑定更自然,而且 View 对单个属性的依赖粒度更细,避免了过去“一个 @Published 属性变了,所有观察这个对象的地方全部重新算”的尴尬情况。
当然,如果你还在支持 iOS 16,只能用 ObservableObject,逻辑上也能跑通,但建议能上 17 就上 17。这里的所有 Demo 都是基于 iOS 17+ 写的。
3. Agent 核心状态机与消息模型设计
3.1 从“聊天”到“状态机”:为什么不能只是数组加字符串
初始天真无邪的版本里,我用了[Message]数组 +TextField输入 +异步刷新,但很快就发现问题:当 Agent 需要“边思考边展示”“边调用工具边反馈状态”时,一个单纯的消息数组根本表达不了此刻处于什么阶段。比如“用户问了天气,Agent 正在调用天气技能”这个状态,你希望 UI 上显示一个加载中的技能卡片,如果消息模型里没有这个字段,就需要在外围额外维护一堆状态标志,代码很快会变成双份维护,容易错乱。
所以我引入了一个最简可用的状态机模型:
enum AgentRuntimeState { case idle // 空闲,等待输入 case thinking // 已发送请求给 LLM,正在等待输出 case streaming(text: String) // 正在流式接收文本增量 case invokingSkill(skill: String, arguments: [String: Any]) // 正在调用某个技能 case waitingForToolResult // 技能执行完毕/失败,准备把结果送回 LLM case error(message: String) // 出错态,可带提示恢复 }ConversationState里保存这个状态值,View 依据它决定每个 UI 区块的显示逻辑:
.thinking:显示“正在思考”的动画气泡。.streaming:显示正在输出的 assistant 消息文字(增量追加)。.invokingSkill:显示一个专门的气泡卡片,包含技能图标、参数摘要、执行动画,用户直观看到 Agent 正在使用某个能力。.waitingForToolResult:在卡片下方显示“已拿到结果,正在组织回复……”之类的短提示。.error:把错误信息渲染成可关闭的红色提示条。
这个状态机设计的核心收益是:每个时刻,UI 和逻辑层的认知是一致的。UI 不需要额外再猜“现在到底卡在哪里”,逻辑层也不需要维护多个独立的布尔标志。
3.2 消息模型与 Flow 状态:让流式回复不再闪烁
聊天的核心数据是消息,但消息不能只存一个字符串。因为流式输出时,一个 assistant 消息可能有三种子类型:纯文本、技能调用、技能结果,而且同一条消息还可能经历“文本 → 技能调用 → 文本”的混合过程。
为此,我设计了一个Message枚举(或者说结构体)如下:
enum MessageContent { case text(String) case toolInvocation(id: String, skillName: String, arguments: [String: Any]) case toolResult(id: String, success: Bool, resultText: String) } struct ChatMessage: Identifiable, Equatable { let id: UUID let role: MessageRole // .user / .assistant var content: MessageContent var createdAt: Date }这里面有一个细节:流的增量不是直接拼进MessageContent.text,而是改在 ConversationState 内部维护一个“当前正在生成的 messageId”,用临时缓冲区累积文本,每累积一段就替换到数组里的对应元素。这样 SwiftUI 只会在文本确实变化时才更新视图,不会因为中间态反复创建新消息导致列表跳动。
实现上大致是这种思路:
@Observable final class ConversationState { var messages: [ChatMessage] = [] private var currentAssistantMessageID: UUID? private var streamingBuffer = "" func startAssistantMessage() { let id = UUID() currentAssistantMessageID = id messages.append(ChatMessage(id: id, role: .assistant, content: .text(""))) } func appendStreamingText(_ text: String) { guard let id = currentAssistantMessageID, let index = messages.firstIndex(where: { $0.id == id }) else { return } streamingBuffer += text messages[index].content = .text(streamingBuffer) } }这么做的直接好处是:UI 上这个 message 是稳定存在的,每次文本刷新都是同一行 Text 的内容变化,SwiftUI 能很好地进行 diff 和动画,不会出现整条消息忽隐忽现的问题。实测在长文本流式输出下,滚动位置和性能都比较稳。
3.3 流式输出与并发:Task 生命周期管理
在流式输出里,最头疼的问题之一就是并发取消和生命周期控制。如果你直接Task { await agentRuntime.send(...) },用户点“停止生成”的时候,你说取消就取消了吗?不一定,因为底层 URLSession 的bytes(from:)会异步返回,你必须保存住当前任务的引用,才能安全 cancel。
我的做法是在 AgentRuntime 里维护一个private var currentTask: Task<Void, Never>?,每次发送新的请求时,先取消旧任务,再创建新任务。取消时不仅要把 URLSession 的流取消掉,还要把状态机改回.idle(或者保留一个“已停止”的中间态,看产品需求)。 这里有一条很实际的经验:永远不要在 View 的.task修饰器里直接裸调 AgentRuntime.send,因为 View 的重构会触发任务反复重建,并发控制会很混乱。正确的姿势是把发送意图交给 ConversationState,再由它驱动 AgentRuntime,View 专注渲染即可。
我还踩过另一个坑:在 Swift 并发环境下,如果你在 Task 内部修改状态,要用MainActor保证主线程更新。最简单的方法是给 AgentRuntime 和 ConversationState 都标上@MainActor,因为 UI 层操作本来就集中在主线程,这种简单粗暴的做法对聊天场景完全够用,也不会引入额外的队列同步复杂度。真要在后台解析大 JSON 时,再 else 用Task.detached或nonisolated处理,但核心状态更新一定回主线程。
4. 技能(Skill)模块与注册表设计
4.1 Skill 协议与标准技能结构
现在到了 Agent 真正“动手干活”的核心部分——Skill 模块。这也是我认为Agent-Skill这个标题里最值得展开的一层。
先定义一个技能协议:
protocol AgentSkill { var name: String { get } var description: String { get } // 告诉 LLM:你这个技能接受哪些参数,参数类型和约束是什么 var parameterSchema: [String: ParameterSchema] { get } // 真正的执行入口,返回值是给 LLM 的“工具执行结果”文本 func execute(parameters: [String: Any]) async throws -> String } struct ParameterSchema { let type: String // 例如 "string" / "integer" / "boolean" let required: Bool let description: String }为什么需要一个parameterSchema字段?因为 Agent 模型需要知道“什么情况下该调用这个技能、参数怎么填”,SDK 或 API 接口通常会把技能描述和参数 Schema 序列化成 JSON 注入到 system prompt 或 tool definitions 中。你可以把 skill 描述理解为“给 AI 看的使用说明书”。说明书写得好不好,直接决定 AI 调用技能的准确率和参数正确率。
一个合格的技能描述,应当做到三件事:
- 名字足够语义化且唯一,例如
query_weather比skill_1好一万倍。 - 描述里写清适用场景和关键条件,例如“当用户询问某个城市当前天气或未来天气预报时使用”。
- 参数描述要明确枚举值和格式要求,例如城市用中文名称、温度单位默认摄氏度等,在描述型字段里“喂”给模型,能大幅降低参数生成错误。
4.2 SkillRegistry:注册表与动态加载
SkillRegistry 是一个简单的单例或注入容器,核心只有注册和查找两个职责:
@MainActor final class SkillRegistry { private var skills: [String: any AgentSkill] = [:] func register(_ skill: any AgentSkill) { skills[skill.name] = skill } func skill(named name: String) -> (any AgentSkill)? { skills[name] } func allSchemas() -> [[String: Any]] { skills.map { skill in [ "name": skill.name, "description": skill.description, "parameters": skill.parameterSchema.mapValues { ["type": $0.type, "description": $0.description] } ] } } }这个设计带来的扩展性收益是决定性的。每新增一个能力,只需要做三件事:
- 写一个新的
struct,实现AgentSkill协议。 - 在 App 启动时调用
registry.register(MyNewSkill())。 - 在 AgentRuntime 发送给模型的 tool definitions 中补充这个技能的 schema(或直接用
allSchemas()动态拼装)。
UI 层、Agent 层、模型层,统统不需要改代码。 我做过的某个项目体验类技能扩展,从需求提出到联调完成,只改了两个文件,花了不到半天时间。如果是在“大一统”代码里,可能要动 View、动网络层、动状态管理,怎么也得折腾一两天,高下立判。
4.3 技能执行的安全边界与超时控制
技能执行并不总是顺利的。网络请求可能超时、参数解析可能失败、技能内部逻辑可能抛异常。为了避免技能卡死整个对话流程,一定要给技能执行套上两层保护:
- 超时保护:用
Task.sleep(nanoseconds:)做一个竞速逻辑(例如 10 秒超时,超时后返回错误结果文本给 LLM)。 - 错误容忍:
execute方法里可以 throws,也可以在方法内部内部 catch 后返回一段“我获取失败了,原因可能是……”的文本交给 LLM。比较推荐后者,因为 LLM 看到失败文本后,有机会自动向用户致歉或尝试其他方案,而不是让整个 Agent 流程直接崩掉。
这里有个容易踩的坑:技能返回的错误文本和数据格式也要遵循给 LLM 的表达规范。比如天气技能超时后返回“error: timeout after 10s, city=Beijing”,模型能明确知道是哪个技能、什么参数、什么错误类型,从而更准确地生成对用户的解释。如果你只返回“失败”,LLM 也就只能干巴巴地说“抱歉,我无法获取天气”,体验会比较生硬。
4.4 工具调用循环:一次对话多次技能执行
前面提到过,Agent 接到用户请求后,可能一次要调用多个技能(比如“帮我查一下明天的天气,并顺便设置一个提醒”)。LLM 的 tool_calls 响应往往包含不止一个工具调用意图。因此 AgentRuntime 内部要实现一个循环结构,我加了一个toolCallLoop(maxIterations: 4)的常量限制,防止模型陷入无限的“调用工具→拿结果→再调用”的死循环。真实项目里,这个限制一般设置 3-5 轮就足够了,太少了复杂需求完成不了,太多则回复延迟非常明显。
循环逻辑的大致伪代码:
var finalText = "" // 开始一轮 LLM 请求 while iteration < maxIterations { let response = try await llmClient.send( messages: messagesHistory, tools: registry.allSchemas() ) if let toolCalls = response.toolCalls, !toolCalls.isEmpty { for call in toolCalls { let resultText = await executeSkill(call) messagesHistory.append(toolResultMessage(call, resultText)) } iteration += 1 continue } else { finalText = response.content ?? "" break } }这段伪代码是整个 Skill 模块的发动机。实际实现时,需要把messagesHistory维护成 LLM 认识的 message 结构(role/content/tool_call_id 等)。这里很重要的一点是:工具调用的中间消息也需要展示给用户看,不能只闷在后台。我在 UI 上用一个“技能卡片”来展示每个正在执行或已完成的工具调用,用户能看到 Agent 在“干什么”,而不是像个黑盒一样干等。这能显著提高交互体感和“智能感”。
5. 实操过程:搭建一个最小完整项目
5.1 Xcode 工程初始配置
开始之前先搭好工程骨架。我的做法是新建一个 iOS App 项目,Interface 选择 SwiftUI,Development Region 随意(一般 English),最低系统版本选择 iOS 17。
为了避免网络权限坑,需要在 Info.plist 配置NSAppTransportSecurity的本地网络访问(如果 API 服务是内网)。如果要连远程 API,默认 HTTPS 是没问题的。但如果使用的是自定义的 HTTP 端口,需要提前加例外规则,否则请求秒挂。我刚开始做本地调试时就在这上面废了不少时间,给后来者提个醒。
工程目录建议这样组织,尽量让界面、状态、能力各自独立:
SwiftUI-Agent-Skill/ ├─ Views/ │ ├─ ChatView.swift │ ├─ MessageBubbleView.swift │ └─ SkillCallCardView.swift ├─ Models/ │ ├─ ChatMessage.swift │ ├─ MessageContent.swift │ └─ AgentRuntimeState.swift ├─ State/ │ └─ ConversationState.swift ├─ Agent/ │ ├─ AgentRuntime.swift │ ├─ LLMClient.swift │ └─ ToolCallParser.swift ├─ Skills/ │ ├─ SkillProtocol.swift │ ├─ SkillRegistry.swift │ └─ Implementations/ │ ├─ QueryWeatherSkill.swift │ ├─ CreateReminderSkill.swift │ └─ CalculateSkill.swift └─ AppEntry.swift不要觉得目录多了麻烦,等你开发到第三轮迭代就会感谢当初这个看起来“过度设计”的规划。技能实现目录独立出来之后,后续加技能就是在Implementations里新增文件而已,非常清爽。
5.2 实现一个完整技能:以“查询天气”为例
为了让你能直接照抄,我用“查询天气”这个最经典的技能完整走一遍。
先定义 schema 和实现:
struct QueryWeatherSkill: AgentSkill { let name = "query_weather" let description = "当用户询问某个城市的当前天气或未来天气预报时使用" var parameterSchema: [String: ParameterSchema] { [ "city": ParameterSchema(type: "string", required: true, description: "城市名称,中文,例如:北京、上海、广州"), "days": ParameterSchema(type: "integer", required: false, description: "未来预报的天数,默认 1,取值 1-7") ] } func execute(parameters: [String: Any]) async throws -> String { guard let city = parameters["city"] as? String, !city.isEmpty else { return "error: missing city parameter" } let days = parameters["days"] as? Int ?? 1 // 这里是真实请求一个天气 API 的气象服务端,演示采用占位 // 实际开发中用 URLSession 发起请求,解析 JSON let weatherText = await WeatherService().fetchWeather(city: city, days: days) return weatherText // 形如 "北京今日晴,气温 14-26 摄氏度,西北风3级,空气质量良好..." } }这个技能实现有几个细节值得注意:
- 参数解析不要用强转一次到位。
parameters["days"] as? Int可能失败,因为大模型有时会传字符串"3"而不是整数 3。为此我写了一个宽松参数解析工具:如果是字符串则尝试转 Int;如果是数字则直接用。这个小细节能极大降低工具调用的失败率。 - 返回值是一段完整可读的文本,不是结构化字典。因为你最终要把这个结果原封不动地给 LLM 去组织语言。返回纯文本比返回结构化 JSON 更省 token,对 LLM 也更友好(它不需要再额外推理“我该怎么把这个 JSON 说成人话”)。
- 执行逻辑内部必须 catch 住所有预期错误。例如城市不存在,可以直接返回“error: city not found”,而不是让异常飞出到外层打断 Agent 流程。
5.3 AgentRuntime 核心实现解析
AgentRuntime 是调用 LLM 和循环处理工具请求的“大脑”。我把它放在@MainActor上,所有公共方法都从主线程调用。
它的核心方法是processUserInput和streamingCompletion。消息历史会被组织成 OpenAI 兼容的格式发送给模型:
system:描述了 Agent 人设和规则,加上一句“你可以调用以下技能来帮助用户,技能定义见 tools 参数”。user:用户输入。assistant:之前的助手输出。tool:工具调用的结果,必须带tool_call_id与之前的工具调用配对。
实战中,我建议在 system prompt 里加入一条:“如果用户的问题与可用技能无关,请直接提供答案,不要强行调用工具。”这能规避模型“为了用工具而用工具”的毛病,尤其是当你注册了六七个技能后,模型有时会莫名其妙地想用计算器来计算“你好”这种输入,很无语,但通过 prompt 约束可以大幅缓解。
流式接收的实现大概这样:
@MainActor final class AgentRuntime { private(set) var state: AgentRuntimeState = .idle private var currentTask: Task<Void, Never>? func send(userInput: String) async { currentTask?.cancel() currentTask = Task { [weak self] in guard let self else { return } await self.runLoop(userInput: userInput) } await currentTask?.value } private func runLoop(userInput: String) async { state = .thinking let messages = buildRequestMessages(userInput) var iteration = 0 while iteration < maxToolIterations { // 此处调用 LLMClient,传入 messages 和 registry.allSchemas() // 如果是流式,会在 textDelta 回调中更新 state = .streaming(text: ...) let response = try await llmClient.generate(messages: messages, tools: schemas) if let toolCalls = response.toolCalls, !toolCalls.isEmpty { for call in toolCalls { state = .invokingSkill(skill: call.name, arguments: call.arguments) let result = await executeSkill(call) messages.append(aToolResultMessage(call.id, result)) } iteration += 1 continue } else { if let finalText = response.content, !finalText.isEmpty { await conversationState.appendStreamingFinal(finalText) } state = .idle break } } if iteration >= maxToolIterations { state = .error(message: "工具调用次数超出限制,请重试") } } }这段代码已经接近可以直接落地的形态。逐行拆解:
currentTask用于支持用户点“停止”时取消当前任务。runLoop里把 Tool Calling 的循环和文本输出统一在一个上下文中,一个函数贯穿整个 Agent 流程。executeSkill方法内部会先查注册表,找不到技能就返回错误文本;找到则执行并做超时保护。- 每轮循环结束都检查
iteration,避免死循环。
5.4 SwiftUI 聊天界面实现要点
现在到了把状态变成界面的最后一步。我不写全量代码,只给关键组件和容易出错的地方。
消息气泡:
struct MessageBubbleView: View { let message: ChatMessage var body: some View { HStack { if message.role == .user { Spacer() Text(message.text) .padding() .background(Color.blue.opacity(0.2)) .clipShape(RoundedRectangle(cornerRadius: 12)) } else { VStack(alignment: .leading, spacing: 6) { if case let .toolInvocation(_, name, arguments) = message.content { Text("调用技能:\(name)") Text("参数:\(arguments)") } else if case let .toolResult(_, success, resultText) = message.content { Text(success ? "执行结果:\(resultText)" : "执行失败:\(resultText)") } else { Text(message.text) } } .padding() .background(Color.gray.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) Spacer() } } } }关键点:用case let在 View 里解构枚举,根据不同的消息内容展示不同的气泡样式,这就是把内容模型设计成枚举而非单一字符串的直接收益。
技能调用卡片:
struct SkillCallCardView: View { let skillName: String let arguments: [String: Any] var body: some View { VStack(alignment: .leading, spacing: 4) { Text("🛠 \(skillName)") // 这里用系统图标 UIImage(systemName:) 也可以,不放 emoji 也行 .font(.caption) .bold() ForEach(arguments.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in Text("\(key): \(String(describing: value))") .font(.caption2) .foregroundColor(.secondary) } } .padding(8) .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) } }注意arguments字体要小一些,因为 Tool Calling 的参数经常有嵌套结构,字号太大会撑爆卡片宽度。这里我没有用 Emoji,实际产品里可以换成 SF Symbols,例如sun.max.fill对应天气技能、bell.fill对应提醒技能,观感会更统一。
列表滚动:聊天记录必须支持长文本自动滚动到底部。我用了ScrollViewReader配合onChange(of: messages.count)自动滑动到最新消息。流式更新文本时,如果消息总数没变,不会触发滚动,需要一个折中方案:当正在 streaming 且当前消息是最后一条时,也主动滚一下,因为文本变量会导致高度变化,列表需要更新可视区域。再强调一遍,这里十分容易踩坑,实测中经常遇到“滚不过去或者滚过头”的问题,最好通过DispatchQueue.main.async或Task { @MainActor in }在文本变更后的下一个运行循环中滚动。
5.5 完整联调场景演示
假设用户发了一句:“北京明天天气怎么样?”
在 AgentRuntime 层会发生什么:
- 把这句话当成 user message 追加到历史。
- LLM 收到 system prompt(带技能列表)+ 用户问题后,判断需要调用
query_weather。 - 返回一个
tool_call,参数大致是{"city": "北京", "days": 1}。 - AgentRuntime 查注册表命中
QueryWeatherSkill,把参数传入执行。 - 天气服务返回“北京明日天气晴,最高 24 度,最低 11 度,北风 2 级”。
- AgentRuntime 把这个 tool result 再次发给 LLM。
- LLM 组织成自然语言:“北京明天是晴天,气温 11 到 24 度,体感舒适,记得带件薄外套。”
- 文本增量流入 ConversationState,UI 流式展示,完成交互。
如果用户接着问:“那后天呢?” 因为历史上下文里有“北京”这个城市,模型可能直接省略掉城市名,然后工具调用参数里可能就没有city。这时候你就要处理“参数继承”或“让模型用默认值”,否则技能会报缺参。比较好的做法是在系统提示里显式写:“如果用户后续问题未提供城市名,可沿用之前讨论的城市,请在城市字段中填充该城市名。”这样模型就比较聪明地帮你补全了。这个细节我是在实际测试中才发现的,不处理的话用户体验会非常断裂。
6. 常见问题与排查技巧实录
6.1 工具调用的参数类型总是对不上
这是我在 Skill 调试中遇到的第一大坑。明明“天”应该是整数,模型却传了个days: "2"。原因主要是当前不少模型对手册式的 schema 遵循程度有限,尤其参数描述写得太模糊或没给示例时,更容易出类型错乱。
解决方法是做一个宽容型的参数解析层:
enum ParameterCasting { static func asInt(_ value: Any?) -> Int? { if let v = value as? Int { return v } if let v = value as? String, let i = Int(v) { return i } if let v = value as? Double { return Int(v) } return nil } }对字符串、布尔、枚举值同理。这个工具函数几乎每个技能都需要,一定写成公用的。
另外,schema 里尽量不要用过于复杂的嵌套结构,模型对单层结构把握得更好。如果需求确实复杂,拆成多个短参数而不是一个大对象,能显著提高生成准确率。
6.2 技能执行后,LLM 说“我不知道调用结果”
这个问题的典型表现是:工具明明执行成功了,Agent 的回复却完全没有体现执行结果,甚至说“我暂时无法查询”。排查下来,绝大多数是消息构造顺序搞错了。
正确的 tool message 结构必须满足三件事:
- tool 消息的
tool_call_id必须等于 assistant 消息里对应 tool_call 的id。 - tool 消息必须插在同一条 assistant(带 tool_calls)消息之后。
- 新一轮请求要带着完整的消息历史,不要截断掉工具调用片段。
有些早期实现为了省 token,只保留最后几条消息,会直接把 tool_call 对应的 assistant 片段丢掉,LLM 自然就“失忆”了。我的建议是:在做长上下文裁剪时,宁可裁掉最早的用户消息,也不要裁掉assistant 带 tool_calls 的消息和对应的 tool 结果消息,否则工具上下文就断了。
6.3 流式回复滚动条乱跳
SwiftUI 聊天列表的滚动问题非常经典,跟图层刷新时机强相关。
我踩过一个非常隐蔽的坑:在用增量文本更新messages.last时,SwiftUI 会触发onChange(of: message.id)之类不必要的副作用,导致滚动到不确定的位置。后来我把“滚动到底部”的逻辑统一收敛到一个函数里,只在三种情况触发:
- 用户发送了消息(消息数量增加)。
- 助手开始生成(新 assistant 消息出现)。
- 流式文本增量到达(且当前滚动位置接近底部,或用户没有向上翻阅历史)。
第三种情况需要判断一下用户是否在往上看历史。如果用户正在翻旧对话,结果被强制滚到底部,那个体验很恼火。可以维护一个scrollOffset的监听,如果当前可视区域顶部不是最新消息,就不触发自动滚动。
6.4 任务取消后,下一次发送会有脏上下文
用户点击“停止生成”时,我们取消了 Task。但如果取消发生在工具调用执行了一半,或者正好在系统把“工具调用结果追加到消息历史”之后、还没返回给 LLM 之前,那么历史里就会多出一条断裂的工具调用消息。下一次发送时,这条“孤儿记录”可能会让模型困惑。
我的解决办法是在取消时做一次“消息历史收尾”:如果存在未完成的 tool_call 气泡,就把这段内容标记为“已取消”或者从历史中移除,并把 assistant 当前消息回退到取消前的文本状态。这个操作虽然有点繁琐,但对连续对话的体验影响很大。实测中如果不管,用户会经常遇到“答非所问”的情况,以为模型变傻了,其实只是历史脏了。
6.5 技能内部调试技巧
技能模块是独立沙盒,最好能脱离 UI 单独跑通。我在项目里加了一个 Debug 入口:在聊天输入框输入/debug_send_weather(这是一个纯开发者指令,不发给 LLM),直接触发QueryWeatherSkill.execute并展示返回结果。这样你验证天气 API 的参数格式、超时逻辑、返回文本质量时,都不用反复和 LLM 对话,效率提升不止一倍。
另一个很实用的技巧是:在调试模式下,把 LLM 返回的原始 JSON(包括 system prompt、tool schemas、每次 tool_call 的完整结构)输出到控制台。看到模型真正收到的指令,才能真正定位“描述不清楚”还是“执行有 bug”,不然总在结果层面瞎猜。
7. 性能优化与工程化建议
7.1 长对话的上下文裁剪策略
LLM 的输入 token 是有限且有成本的。对话超过一定轮数后,必须做裁剪或者摘要压缩。我的做法是把历史消息分成三档:
- 最近 10 轮:完整保留,包括 tool_call 的完整对话。
- 更早的部分:保留最近几轮的人物或事件主题摘要,存为一个 system 提示词。
- 更早更细的:只保留用户最后一次提及的实体关键词,避免上下文丢失太多。
如果不想做得太重,一个中等偏上且够用的方案是:设置一个最大 token 预算(例如 8000),超出后从最旧的消息开始裁剪,但始终保留最后一条 assistant tool_call 消息和全部对应的 tool 结果消息。这是我反复验证后在“效果”和“成本”之间找到的平衡点。
7.2 避免重复解析与状态刷新的性能陷阱
SwiftUI 的性能问题通常不是渲染本身,而是“不必要的重绘”。有几个经验值得记录:
- 技能调用卡片的参数展示:不要在卡片里展示巨大的 JSON 字符串,只显示前几个核心参数,细节折叠到“详情”按钮里。
- 流式文本更新:避免在
.id(message.id)渲染视图里做复杂计算,气泡里的文本节点直接Text(streamingText),不用 Markdown 渲染。如果必须渲染 Markdown,在增量末尾加一个空行让布局更稳定。 - 列表 item 使用
Equatable:消息模型实现了Equatable,配合.equatable()修饰器可以降低无关消息的刷新范围。
7.3 模块化打包与组件复用
如果你的公司或团队有多个 App,考虑把 SkillRegistry、AgentRuntime、MessageModel 这套做成独立的 Swift Package,UI 层作为另一个 Package。这样背后的大模型服务甚至都可以抽象成一个协议,不同 App 接入不同的模型厂商,但 ConversationState 和 Skill 生态是共用的。
我的一个实际经历是,在某个模拟项目里先把这套代码作为内部包抽出来,另一个 App 直接以依赖方式接入,很快就复用了所有已注册技能,UI 只额外定制了个性化主题。这个复用收益,是写在“大一统代码”里永远享受不到的。
8. 写在最后的一点体会
从最初用 Button 塞一个 API 请求,到最终形成“Agent 状态机 + Skill 注册表 + SwiftUI 声明式渲染”这套结构,整个过程最大的体会是:不要把 Agent 想象成一个“对话框”,而要把它想象成一个“协调者”。它一边要读懂用户的含糊意图,一边要把任务分配给它手里那群各司其职的 Skill 工具。真正让它有价值的地方,恰恰是那一个个能被它指挥、又能独立打磨的 Skill 模块。
最后再分享一个细节:我在上线前的最后阶段,把所有技能的名字和描述重新逐字过了一遍,删掉了所有可有可无的形容词,并把“param 描述”里补上了枚举取值示例。看起来是文案工作,但实测技能调用成功率从 78% 提到了 93% 左右。这种“提示词工程下沉到技能层”的思路,成本极低、见效极快,值得每个做 Agent 的开发者复盘一下。
这条路目前仍然在快速演进,不同模型对工具调用的支持细节也越来越丰富,但“UI 状态化、能力模块化、大脑调度化”这个大方向不会变。如果你也在做类似的东西,希望这套实操能帮你少走几个不算短的弯路。