中大模型工具調(diào)用與技能調(diào)度實踐)
這陣子我一直在折騰一個基于 SwiftUI 的“Agent Skill”項目也就是標題里那個SwiftUI-Agent-Skill。簡單說這是一個把大模型對話能力和系統(tǒng)功能調(diào)用打通的應(yīng)用框架用戶用自然語言說話Agent 負責(zé)理解意圖然后調(diào)度到具體的 Skill 去執(zhí)行最后把結(jié)果再帶回對話里。你問“幫我明天上午十點約個會議室”它不只是回一句“好的”而是真的往系統(tǒng)日歷里寫一條日程。這種模式在蘋果生態(tài)里做起來比想象中麻煩因為 SwiftUI 本身只是個 UI 框架不負責(zé)意圖識別也不負責(zé)工具調(diào)度。我踩了不少坑之后把這套東西拆成了幾個清晰的模塊今天這篇就完整梳理一遍從架構(gòu)設(shè)計、協(xié)議定義、并發(fā)處理到實際接入 SwiftUI 視圖和排錯經(jīng)驗。適合正在做 iOS / macOS 智能助手類應(yīng)用、或者想在蘋果端落地 Agent 能力的開發(fā)者參考。無論你是剛接觸 Swift 還是已經(jīng)有幾年經(jīng)驗這篇文章里的關(guān)鍵代碼和取舍邏輯應(yīng)該都能幫你少走幾個彎路。1. 項目整體設(shè)計與思路拆解1.1 項目定位與核心需求SwiftUI-Agent-Skill本質(zhì)上解決的是一個很具體的問題大模型本身只能輸出文本它沒法直接操作你的日歷、備忘錄、文件系統(tǒng)也發(fā)不了網(wǎng)絡(luò)請求。要讓 AI 真正“干活”你必須給它提供一套可執(zhí)行的工具。這就是 Skill 的定位。Agent 則是調(diào)度的大腦。它負責(zé)幾件事把用戶的自然語言輸入整理成結(jié)構(gòu)化請求根據(jù)請求選擇合適的 Skill解析 Skill 執(zhí)行結(jié)果再組織成用戶能看懂的自然語言回復(fù)這個分工其實和很多后端 Agent 框架的思路一致但在 SwiftUI 場景下有幾個額外訴求狀態(tài)要能驅(qū)動 UI 刷新、異步任務(wù)要跑得穩(wěn)、Skill 要做得易擴展。比如用戶問完問題之后界面上要能看到“思考中 → 正在調(diào)用日程工具 → 已寫入日歷”這樣的實時反饋否則體驗會非常生硬。我最初也想過直接用某個現(xiàn)成的 SDK 把 Function Calling 包裝一下但試了一圈發(fā)現(xiàn)要么是重量級 SDK 綁定了特定服務(wù)商要么是框架層強調(diào)服務(wù)端邏輯、在客戶端集成很別扭。最后決定自己封裝一套輕量的 Agent-Skill 內(nèi)核這樣既能適配任意 LLM 服務(wù)也可以隨時往注冊表里加新技能不需要改動核心代碼。1.2 技術(shù)選型背后的理由很多 Swift 開發(fā)者會問為什么不用ObservableObject而是用Observable為什么 Skill 協(xié)議里要帶一個描述用的元數(shù)據(jù)這些選擇背后都有實際原因。先說狀態(tài)管理。iOS 17 以后蘋果主推Observable它在視圖刷新粒度上比ObservableObject更精準ObservableObject只要任何一個Published屬性變了整個觀察體就刷新Observable則只刷新真正被讀取屬性的視圖。這對 Agent 這種狀態(tài)變化頻繁的場景很重要尤其是對話界面里的消息列表、技能執(zhí)行進度、輸入框的可用狀態(tài)這些控件各自關(guān)心不同的狀態(tài)沒必要一鍋端地全部重繪。再說 Skill 的描述。大模型是根據(jù)字符串描述來“理解”一個工具能不能用的所以每個 Skill 不能只提供一個執(zhí)行方法還要提供一個面向 LLM 的說明書——包含技能名稱、觸發(fā)場景、參數(shù)結(jié)構(gòu)。這個說明書寫得好不好直接決定模型能不能正確調(diào)用技能。后面我會專門展開講這塊因為它是我在整個項目里調(diào)試時間最長、收益也最明顯的部分。1.3 模塊劃分與數(shù)據(jù)流我最終把項目拆成了四個核心模塊模塊職責(zé)關(guān)鍵內(nèi)容AgentCore意圖理解、對話管理接收輸入、維護消息歷史、解析 LLM 返回的 tool_callSkillRegistry技能注冊與查詢保存所有 Skill 實例、向 LLM 提供技能描述列表Skill 層具體執(zhí)行能力每個 Skill 獨立實現(xiàn)輸入解碼、系統(tǒng)調(diào)用、結(jié)果輸出SwiftUI UI 層展示與交互消息列表、技能執(zhí)行狀態(tài)卡片、輸入欄數(shù)據(jù)流是單向的用戶在界面輸入 → AgentCore 組裝請求 → 攜帶 SkillRegistry 的技能描述一起發(fā)給 LLM → LLM 決定調(diào)用哪個 Skill 并返回結(jié)構(gòu)化參數(shù) → AgentCore 查注冊表拿到對應(yīng) Skill 實例 → 執(zhí)行并拿到結(jié)果 → 結(jié)果再交給 LLM 生成最終回復(fù) → UI 展示。整個過程里SkillRegistry 是一個純內(nèi)存的注冊中心不持有任何業(yè)務(wù)邏輯只做匹配和分發(fā)。這樣新增技能時只需要初始化一個實例并調(diào)用register方法完全不用改動 Agent 核心。2. 核心細節(jié)解析與實操要點2.1 Skill 協(xié)議設(shè)計與擴展性Skill 協(xié)議是整個框架的地基設(shè)計得不好后面每加一個新能力都難受。我的最終版本是這樣的protocol Skill: Identifiable, Sendable { var id: String { get } var descriptor: SkillDescriptor { get } func execute(input: Data) async throws - SkillResult }協(xié)議只有三個要求id 用于注冊表索引descriptor 是給 LLM 看的描述和參數(shù)結(jié)構(gòu)execute 是真正的執(zhí)行入口。input 用Data而不是具體的 Codable 類型是為了讓協(xié)議不綁定任何特定參數(shù)結(jié)構(gòu)——每個 Skill 自己決定如何解碼輸入這樣有的技能可能要傳經(jīng)緯度坐標有的要傳聯(lián)系人列表都可以各自維護自己的輸入模型。SkillDescriptor是支撐整個調(diào)度過程的關(guān)鍵struct SkillDescriptor: Codable, Sendable { let id: String let name: String let description: String let parametersSchema: String }調(diào)用 LLM 工具接口時系統(tǒng)需要傳一個 JSON 格式的工具列表parametersSchema就是直接塞給模型看的。這個字段我用 JSON Schema 格式編寫大多數(shù)商業(yè) LLM 的 Function Calling 接口都能直接兼容不需要額外的格式轉(zhuǎn)換。實際項目中我加過定時器、網(wǎng)絡(luò)請求、本地搜索等技能每次新增都只需要關(guān)注三件事技能描述怎么寫、參數(shù) schema 怎么定、execute 里怎么調(diào)用系統(tǒng) API。AgentCore 和 UI 層零修改這一點讓我非常滿意——框架的價值就在于“加功能不改核心”。2.2 技能描述怎么寫模型才聽得懂這是我在整個項目里最想強調(diào)的經(jīng)驗Skill 的描述不是給人看的注釋而是給模型看的“操作說明”。大多數(shù)開發(fā)者在第一步就把這事想簡單了寫個“創(chuàng)建日程”四個字就完事結(jié)果就是模型根本沒法判斷什么時候該調(diào)這個工具。我對比過幾輪實驗差別非常明顯描述類型實際效果“創(chuàng)建日程”模型經(jīng)常在用戶只提到“開會”時就誤調(diào)用且參數(shù)經(jīng)常漏填日期帶觸發(fā)場景、參數(shù)約束、缺省策略的完整描述調(diào)用時機準確、參數(shù)完整率明顯提升用戶在時間不全時會被反問問清楚最終我給日程技能寫的是這樣一段當用戶想要安排會議、創(chuàng)建提醒、規(guī)劃待辦事項時調(diào)用本技能。系統(tǒng)會讀取用戶提供的日期和時間自動寫入系統(tǒng)日歷。參數(shù) title 為事件標題startDate 為 ISO8601 格式的開始時間durationMinutes 為持續(xù)分鐘數(shù)。如果用戶沒有給出具體時間不要調(diào)用本技能應(yīng)當先向用戶詢問開始時間。注意最后這句話——“如果用戶沒給出具體時間不要調(diào)用”。這是很多開發(fā)者容易遺漏的關(guān)鍵約束。模型默認傾向于“猜測并執(zhí)行”如果你不明確告訴它何時不該調(diào)用它會在信息不全時強行填一個當前時間進去。加上這個反例描述之后模型的調(diào)用準確率提升非常顯著。另外我強烈建議在 description 里加入“示例”。比如對天氣查詢技能補一句“例如用戶問‘上海明天冷不冷’應(yīng)當傳 location上海date明天”模型會參考這個示例來構(gòu)造參數(shù)這比單純羅列字段描述有效得多。你可以把這理解為喂給模型的小樣本提示Function Calling 的場景下少量示例往往比一個嚴密的 JSON Schema 更管用。2.3 Agent 狀態(tài)驅(qū)動的 UI 刷新Agent 的運行不是一蹴而就的發(fā)起請求、等待模型返回、執(zhí)行技能、再次請求模型生成回復(fù)每個環(huán)節(jié)都有狀態(tài)變化。為了把過程展示清楚我定義了一個顯式的狀態(tài)枚舉enum AgentState: Equatable { case idle case thinking case executing(skill: String) case responding }這個狀態(tài)配合Observable之后SwiftUI 視圖可以直接根據(jù)狀態(tài)切換展示內(nèi)容thinking時顯示轉(zhuǎn)圈并輸出“正在思考”executing(skill:)時顯示一個技能卡片標注當前正在執(zhí)行的工具名稱responding時顯示“正在組織回復(fù)”。我用了一個小技巧把狀態(tài)映射成界面上的“階段指示器”類似一個 Step 列表用戶能清楚看到當下進行到哪一步。實測下來這種過程可見性對主觀體驗的提升巨大哪怕底層響應(yīng)慢一點用戶也會覺得應(yīng)用更“聰明”而不是卡頓。3. 實操過程與核心環(huán)節(jié)實現(xiàn)3.1 從零搭建技能注冊中心注冊中心是整個框架的核心數(shù)據(jù)結(jié)構(gòu)我設(shè)計得很輕本質(zhì)上是一個帶鎖的字典加一個描述列表MainActor final class SkillRegistry: ObservableObject { private var storage: [String: any Skill] [:] func register(_ skill: any Skill) { storage[skill.id] skill } func skill(for id: String) - (any Skill)? { return storage[id] } var descriptors: [SkillDescriptor] { storage.values.map { $0.descriptor } } }我把它標成MainActor是有原因的Agent 的執(zhí)行和 UI 狀態(tài)經(jīng)常同步讀寫放到主 actor 上可以避免一堆數(shù)據(jù)競爭問題。實際調(diào)用 Skill 的時候雖然可能涉及網(wǎng)絡(luò)請求、文件讀寫但 Skill 內(nèi)部自己開異步子任務(wù)注冊中心只負責(zé)同步查詢和枚舉因此不會阻塞 UI。App 啟動時把所有內(nèi)置技能注冊進去let registry SkillRegistry() registry.register(CreateEventSkill()) registry.register(WeatherQuerySkill()) registry.register(ReminderSkill())這一步我看到很多人在做的時候會糾結(jié)要不要用“自動掃描”或“反射注冊”在 Swift 生態(tài)里沒必要手動注冊幾個技能是十幾秒的事?lián)Q來的是清晰的依賴關(guān)系和編譯時就有的類型檢查。3.2 實現(xiàn)一個真正可用的技能日歷日程寫入以日程創(chuàng)建技能為例看一個完整的 Skill 實現(xiàn)長什么樣。輸入?yún)?shù)用嵌套結(jié)構(gòu)這樣執(zhí)行方法里解碼會很干凈struct CreateEventSkill: Skill { var id: String { create_event } var descriptor: SkillDescriptor { SkillDescriptor( id: id, name: 創(chuàng)建日歷日程, description: 當用戶想要安排會議、創(chuàng)建提醒、規(guī)劃待辦事項時調(diào)用……, parametersSchema: { type: object, properties: { title: { type: string, description: 日程標題 }, startDate: { type: string, format: date-time, description: 開始時間ISO8601格式 }, durationMinutes: { type: integer, description: 持續(xù)分鐘數(shù)默認30 }, notes: { type: string, description: 日程備注可選 } }, required: [title, startDate] } ) } private struct Input: Codable { let title: String let startDate: String let durationMinutes: Int? let notes: String? } func execute(input: Data) async throws - SkillResult { let params try JSONDecoder().decode(Input.self, from: input) let store try await withCheckedThrowingContinuation { continuation in // EventKit 的授權(quán)和寫入代碼省略 } let message 已創(chuàng)建日程\(params.title)開始時間 \(params.startDate) return SkillResult(success: true, output: message) } }注意durationMinutes在 Input 中是可選類型。模型經(jīng)常不填不必要字段如果把它設(shè)為必填會因為缺參數(shù)導(dǎo)致解碼失敗。所以在 schema 里我只把title和startDate設(shè)成 required其他字段要么給默認值要么走“可選 代碼兜底”的策略。這一點非常關(guān)鍵LLM 輸出的 JSON 不像人手寫的那么穩(wěn)定寬松解碼、默認值兜底能省掉大量調(diào)錯時間。3.3 自然語言到技能調(diào)用的完整流程有了注冊中心和 Skill 實例接下來是 AgentCore 的核心循環(huán)。我用的是一個比較標準的工具調(diào)用流程func processUserInput(_ text: String) async { state .thinking let messages buildMessages(addingUserText: text) let firstResponse try await llmProvider.chat( messages: messages, tools: registry.descriptors ) // 模型決定調(diào)用工具 if let call firstResponse.toolCall { state .executing(skill: call.name) guard let skill registry.skill(for: call.name) else { // 技能不存在把錯誤反饋給模型 return } do { let result try await skill.execute(input: call.argumentsData) // 工具結(jié)果回傳給模型讓模型組織自然語言回復(fù) let finalResponse try await llmProvider.chat( messages: messages [ toolResultMessage(name: call.name, content: result.output) ] ) appendMessage(finalResponse) } catch { // 失敗時直接把錯誤信息作為工具結(jié)果給模型 let errorMessage toolResultMessage( name: call.name, content: 執(zhí)行失敗\(error.localizedDescription) ) let retryResponse try await llmProvider.chat(messages: messages [errorMessage]) appendMessage(retryResponse) } } else { // 模型沒有調(diào)用工具直接回復(fù) appendMessage(firstResponse.message) } state .idle }這個流程最精華的地方在第 24 行到第 30 行Skill 執(zhí)行失敗時不是簡單地把錯誤拋給用戶而是把錯誤信息重新交給 LLM讓模型自己決定是修正參數(shù)重試、還是換一個表達方式解釋問題。比如模型調(diào)用日程技能時傳了一個已經(jīng)被占用的事件 ID技能返回錯誤模型會在下一輪回復(fù)中告訴用戶“這個時段已有日程建議換到下午三點”而不是顯示一句冷冰冰的系統(tǒng)錯誤。實際測試時我還發(fā)現(xiàn)給模型傳參數(shù)時保留原始 JSON 比編碼成字符串更可靠。call.argumentsData是模型返回的原始 JSON 數(shù)據(jù)解碼成Input時類型更干凈。如果中間轉(zhuǎn)一次字符串格式偶爾會多出轉(zhuǎn)義符導(dǎo)致解碼失敗。3.4 接入 SwiftUI 視圖層UI 層我用一個AgentViewModel封裝狀態(tài)和邏輯Observable final class AgentViewModel { var messages: [ChatMessage] [] private(set) var state: AgentState .idle private let registry SkillRegistry() private let agent AgentCore() func send(_ text: String) async { let userMessage ChatMessage(role: .user, content: text) messages.append(userMessage) await agent.processUserInput(text) } }視圖層就變得很直白了struct ChatView: View { State private var vm AgentViewModel() var body: some View { VStack { List(vm.messages) { message in MessageRow(message: message) } if case .executing(let skillName) vm.state { SkillStatusCard(name: skillName) } InputBar { Task { await vm.send($0) } } } } }SkillStatusCard是我額外加的一個小組件它把“當前在調(diào)用什么技能”變成可見狀態(tài)。你可以在這里加進度條、技能圖標、耗時統(tǒng)計都不影響核心邏輯。這個設(shè)計還有一個好處以后要接 IPC、推送或桌面小組件來展示 Agent 進度可以直接復(fù)用同一個狀態(tài)源。4. 常見問題與排查技巧實錄這一節(jié)的內(nèi)容是我在真機調(diào)試和連續(xù)多天日常使用中積攢下來的。很多坑是意想不到的列出來給各位參考。4.1 模型返回的參數(shù) JSON 不合法LLM 輸出的tool_calls里的參數(shù)不一定每次都是合法 JSON。我遇到比較多的情況包括字段名被縮寫、時間格式不是 ISO8601、數(shù)字被判定成字符串等。最典型的一次是模型把startDate: 2025-03-01 10:00傳進來而不是2025-03-01T10:00:00Z導(dǎo)致解碼器直接拋錯。我的解決方案是分兩層處理。第一層是捕獲解碼錯誤時把錯誤信息回傳給 LLM讓模型自己修正格式第二層是給核心字段寫寬松解析比如時間字段用ISO8601DateFormatter嘗試多種格式解析不了再拋錯。這兩層配合后絕大部分情況都能自動通過。4.2 并發(fā)更新 UI 導(dǎo)致視圖卡頓早期版本我直接在execute方法里調(diào)用 EventKit 寫入EventKit的接口是同步的寫日歷的操作會阻塞當前線程。因為 AgentCore 的調(diào)用鏈是在主線程上跑的結(jié)果就是點擊發(fā)送后整個界面僵住一兩秒。排查思路很直接在 Xcode 的 Instruments 里看 Time Profiler發(fā)現(xiàn)主線程上有大量 EventKit 堆棧占用。修復(fù)方式是所有耗時操作都放到獨立的異步函數(shù)里并用await等待確保主線程只做 UI 更新??蚣軐用嬉部梢约右坏婪烙oSkillRegistry加上MainActor但 Skill 的execute用nonisolated或者放到后臺任務(wù)隊列里執(zhí)行。4.3 同一個技能被并發(fā)調(diào)用導(dǎo)致狀態(tài)沖突多輪對話里用戶可能在上一輪技能還沒執(zhí)行完就發(fā)送了新消息。比如問“幫我查天氣”馬上又跟一句“順便建個日程”。如果不加控制兩條流程會同時執(zhí)行日歷寫入就可能和天氣查詢的狀態(tài)互相覆蓋。我最后用了一個簡單的串行隊列AgentCore 內(nèi)部維護一個Task鏈新的輸入會等待上一個任務(wù)結(jié)束后再執(zhí)行。極端情況下你可以用AsyncSemaphore做并發(fā)控制但對絕大多數(shù)應(yīng)用場景來說串行處理用戶輸入是符合預(yù)期的——因為對話本身就有先后順序用戶很少需要真正并發(fā)的 Agent 處理。4.4 技能注冊了但模型就是不調(diào)用這個問題最隱蔽也是最讓人惱火的技能明明注冊了描述寫得也不錯但模型就是視而不見。我排查后發(fā)現(xiàn)主要是兩個原因。第一個原因是注冊順序。有些 LLM 服務(wù)商對工具列表的長度和順序敏感工具過多時偶爾會忽略排在后面的工具。解決辦法是給工具列表按調(diào)用頻率排序常用技能放前面或者把一個 Agent 負責(zé)的技能數(shù)量控制在 5-8 個以內(nèi)。第二個原因是名字和描述里的關(guān)鍵詞不匹配。模型是根據(jù)文本相關(guān)性來判斷何時調(diào)用技能的如果用戶在輸入里用了“改期”“挪一下時間”而技能描述里只寫了“創(chuàng)建日程”模型就會猶豫。我的經(jīng)驗是在描述里把常見的同義說法都列一遍“改期、延期、換時間、重新安排都可以調(diào)用本技能”。這樣模型的命中率會大幅提高。4.5 狀態(tài)機狀態(tài)殘留與 UI 卡死還有一個值得記錄的問題如果用戶發(fā)送消息后立刻切后臺或者 LLM 請求超時狀態(tài)機可能停在executing狀態(tài)界面一直顯示“正在調(diào)用技能”。排查后發(fā)現(xiàn)是異常分支沒有清理狀態(tài)。修復(fù)方式是在processUserInput外面套一個defer確保無論什么路徑退出最終都會回到state .idle。這個經(jīng)驗對所有異步狀態(tài)機都通用狀態(tài)恢復(fù)必須和退出綁定而不是只放在成功分支里。為了便于參考我把典型問題整理成一個簡潔的速查表問題根因處理方案參數(shù)解碼失敗LLM 輸出的 JSON 格式不合規(guī)寬容解碼 錯誤信息回傳 LLM 重試界面卡頓同步操作阻塞主線程耗時任務(wù)異步化主線程只刷 UI狀態(tài)互相覆蓋并發(fā)處理用戶輸入AgentCore 內(nèi)維護串行任務(wù)隊列技能不觸發(fā)描述缺少場景關(guān)鍵詞列全同義說法控制技能總數(shù)狀態(tài)卡死異常分支未恢復(fù)狀態(tài)用 defer 確保狀態(tài)回歸 idle5. 性能優(yōu)化與后續(xù)擴展路徑5.1 抽象 LLM Provider 以支持切換框架運行的核心依賴是 LLM 返回結(jié)果但我不希望整個項目綁死在哪一家模型服務(wù)上。所以我抽了一個LLMProvider協(xié)議讓所有模型請求都走統(tǒng)一接口protocol LLMProvider: Sendable { func chat( messages: [ChatMessage], tools: [SkillDescriptor] ) async throws - LLMResponse }這樣 AgentCore 完全不知道底層是云端大模型、私有化部署還是端側(cè)模型只要實現(xiàn)協(xié)議就能運行。我日常調(diào)試用的是一個輕量級云端接口同時也在實驗把簡單意圖識別分流到端側(cè)模型減少不必要的網(wǎng)絡(luò)請求。這個抽象對后續(xù)做多模型切換、灰度測試、離線模式都很有價值。5.2 從單技能調(diào)用走向多技能編排當前實現(xiàn)處理的是“一輪對話調(diào)用一個技能”但實際用戶需求經(jīng)常是復(fù)合的“幫我查下明天天氣如果下雨就提醒我?guī)恪?。這需要 Agent 具備多步推理和編排能力。我計劃引入一個簡單的計劃器當意圖識別為復(fù)合任務(wù)時Agent 先拆解出子任務(wù)清單逐個執(zhí)行對應(yīng) Skill再用最終結(jié)果組裝回復(fù)。技術(shù)上就是在一個循環(huán)里重復(fù)“調(diào) LLM → 執(zhí)行工具 → 回傳工具結(jié)果”直到 LLM 認為所有子任務(wù)完成。這個模式的工程改造成本不大但能明顯提升真實場景的可用性。5.3 離線技能與隱私邊界蘋果生態(tài)里用戶對隱私的敏感度很高這個項目也天然適合做端側(cè)優(yōu)先本地日歷、提醒、文件都是不需要聯(lián)網(wǎng)就能執(zhí)行的 Skill。真正需要聯(lián)網(wǎng)的只有 LLM 請求本身。我調(diào)研過 iOS 17 起引入的端側(cè)自然語言框架可以用它做短文本的意圖分類把“打開設(shè)置”“新建備忘錄”這類簡單請求直接映射到本地技能不經(jīng)過網(wǎng)絡(luò)響應(yīng)速度快、隱私性也好。LLM 更多是被用來處理需要復(fù)雜理解的長尾請求。這種混合調(diào)度的思路既控制了成本也提升了體驗是后續(xù)比較理想的演進方向。最后說一個我實際開發(fā)中的體會Agent 項目調(diào)試時最大的成本其實不在代碼而在于和 LLM 交互時的“預(yù)期管理”。你寫了一個技能描述以為模型一定能理解但它就是會用偏。我的辦法是在開發(fā)階段寫了不少自動化測試用例把典型用戶語句喂進去檢查模型是否選對技能、參數(shù)是否合理。這樣每次調(diào)整描述或 schema 后跑一遍用例回歸很快就能迭代出穩(wěn)定的表現(xiàn)。如果你打算把這套模式用到自己的項目里我強烈建議你從第一天就保留這樣的測試集它會是你最可靠的護航工具。