1. 为什么要把 SDK 塞进 macOS 原生 Agent 应用
如果你正在用 SwiftUI 写一个 macOS 桌面 Agent,大概率会遇到一个绕不开的架构选择:Agent Loop 到底跑在哪。跑在外部进程里,意味着你要维护一个 CLI 二进制的启动、签名、路径查找和进程间通信;跑在应用进程内,则意味着你可以用 Xcode 断点直接调试、用 Swift 并发直接编排、用毫秒级延迟替代进程冷启动。
我这次要做的,就是把一个原本依赖外部opencode serve进程的 SwiftUI Agent 应用,改成在进程内直接调用 SDK 的Agent.stream()。同时把模型调用通道统一改到 TaoToken,用一个 Key 覆盖 Anthropic 和 OpenAI 兼容两种 provider。TaoToken 在这里扮演的角色是统一 Key 通道:你不需要在客户端里分别维护多家厂商的 Key 和 Base URL,只需要在 SDK 初始化时注入一个apiKey和一个baseURL,剩下的 provider 映射交给配置层处理。
这篇文章面向的是已经有一定 Swift/SwiftUI 基础、正在做 macOS 原生 Agent 应用的开发者。我会从 Xcode 工程配置讲起,到依赖管理、Key 与 Base URL 注入、请求封装、端到端验证,最后给出一份排错清单。所有配置片段都可以直接复制到你的工程里。核心检索词是 macOS 原生 Agent 应用集成 SDK,适合谁:正在用 SwiftUI 构建桌面 Agent、想把模型调用通道统一、想摆脱外部二进制依赖的开发者。
先说清楚目标架构。原来的结构是:SwiftUI App 通过一个 actor 桥接层,启动外部opencode serve进程,用 REST API 发 prompt,用 SSE 收流式事件。这套架构能用,但有几个现实问题:用户要自己装 CLI,应用要处理二进制签名和路径查找;REST + SSE 意味着事件要经过 HTTP 序列化/反序列化;外部进程冷启动需要 2 到 5 秒;跨进程问题很难定位。
改造后的结构是:保留原有的外部进程桥接作为备选,新增一个进程内 SDK 桥接,用一个 enum 分派层让 UI 层无感知切换。SDK 桥接的核心是一个 actor,负责接收配置、创建 Agent、调用Agent.stream()、把 SDK 消息映射成应用已有的事件类型。这样 UI 层的事件处理逻辑完全不用改。
为什么用 enum 而不是 protocol?因为两个桥接层的能力不完全一样。外部进程桥接有权限请求、问题回复等概念,SDK 桥接不需要这些。用 enum 可以在共享接口上做统一分派,同时保留各自特有的方法,SDK 后端对不支持的方法直接 no-op 即可。
2. TaoToken 前置:统一 Key 通道与 Base URL 注入
在动手改代码之前,先把 Key 通道这件事理清楚。TaoToken 的定位是统一模型调用通道,你可以在它的控制台创建一个 API Key,然后在 SDK 初始化时把这个 Key 和对应的 Base URL 注入进去。对于 macOS 原生 Agent 应用来说,这意味着你不需要在客户端里硬编码多家厂商的地址,也不需要为每个 provider 单独写一套认证逻辑。
你需要先拿到两样东西:一个 API Key,以及确认要用的模型 ID。API Key 在控制台的 API Keys 页面创建,模型 ID 可以在模型对话页面先试一下,确认这个模型在你的账号下可用。接入文档里有完整的 Base URL 和请求格式说明。如果你打算长期跑编码类 Agent,Coding Plan 页面有更详细的套餐说明。
这里要强调一个工程原则:Key 绝对不要硬编码进 Swift 源码。macOS 应用会被打包成.app,源码里的字符串可以被strings命令直接提取。正确的做法是通过环境变量或者本地配置文件注入,并且在.gitignore里排除配置文件。我在工程里用的是UserDefaults加环境变量双通道:开发期用环境变量,运行期用设置界面写入的配置。
Base URL 的注入点很关键。SDK 的AgentOptions里有一个baseURL字段,这个字段会直接决定请求发往哪里。如果你用的是 Anthropic 协议,SDK 会在baseURL后面拼接/v1/messages;如果你用的是 OpenAI 兼容协议,拼接的是/v1/chat/completions。所以baseURL要填到域名级别,不要自己带/v1,否则会拼出/v1/v1/messages这种错误路径。
provider 映射也要注意。应用层通常用字符串表示 provider,比如"anthropic"或"openai",而 SDK 用的是枚举LLMProvider。你需要在创建 Agent 之前做一次转换。我维护了一个anthropicProviders集合,命中就映射成.anthropic,否则映射成.openai。这个集合要跟你的设置界面里的 provider 列表保持一致,否则会出现「设置里选了但 SDK 不认」的情况。
还有一个容易被忽略的点:不是所有 provider 都需要 API Key。本地跑的 Ollama、LM Studio 就不需要。但 SDK 默认要求apiKey不为空。我的处理方式是在配置阶段检查 provider 是否允许空 Key,如果允许就传空字符串,SDK 会跳过认证 header。这个判断要放在创建 Agent 之前,否则会直接抛错。
最后说下安全边界。TaoToken 是合规的模型调用通道,你在客户端里注入的只是它签发的 Key。不要把 Key 写进任何会被提交到版本库的文件,也不要在日志里打印完整 Key。我在 debug 模式下只打印 Key 的前 8 位和后 4 位,中间用星号替代,这样既能确认配置生效,又不会泄露。
3. 可复制配置:Xcode 工程、依赖与 settings 片段
这一节给你可以直接复制的配置。先说 Xcode 工程层面的设置。macOS 原生应用要调用网络和启动子进程,需要在 target 的 Signing & Capabilities 里确认 App Sandbox 的配置。如果你要启动 MCP stdio 子进程,com.apple.security.inherit和文件读写权限要开。网络请求需要com.apple.security.network.client。这些在.entitlements文件里配置。
依赖管理我用的是 Swift Package Manager。在 Xcode 里通过 File > Add Package Dependencies 添加 SDK 的仓库地址,然后在 target 的 General > Frameworks, Libraries, and Embedded Content 里确认链接。如果你用的是Package.swift管理,片段如下:
// Package.swift let package = Package( name: "MotiveAgent", platforms: [.macOS(.v14)], dependencies: [ .package(url: "https://github.com/your-org/OpenAgentSDK.git", from: "1.0.0") ], targets: [ .target( name: "MotiveAgent", dependencies: ["OpenAgentSDK"] ) ] )接下来是配置结构体。这是 SDK 桥接层接收的配置,字段跟AgentOptions对齐。注意baseURL和apiKey的来源,我建议从环境变量读,读不到再回退到UserDefaults:
// SDKBridge.swift struct Configuration: Sendable { let apiKey: String let model: String let provider: String // "anthropic" 或 "openai" let baseURL: String? // TaoToken 的 Base URL let debugMode: Bool let projectDirectory: String let mcpEntries: [String: MCPEntry]? let env: [String: String]? let skillDirectories: [String]? } struct MCPEntry: Sendable { let command: String let args: [String]? let env: [String: String]? }环境变量注入的片段。在 Xcode 的 Scheme > Run > Arguments > Environment Variables 里加这两条,开发期就能直接读到:
TAOTOKEN_API_KEY=sk-你的Key TAOTOKEN_BASE_URL=https://taotoken.net/api如果你用.xcconfig文件管理,可以这样写。注意.xcconfig不要提交到公开仓库:
// Config.xcconfig TAOTOKEN_API_KEY = sk-你的Key TAOTOKEN_BASE_URL = https:/$()/taotoken.net/api创建 Agent 的核心片段。这里有几个关键点:provider 映射、core + specialist 工具始终包含、MCP 服务器转换、sessionStore 持久化:
// SDKBridge.swift private func createAgent(from config: Configuration, sessionId: String? = nil) -> Agent { let provider: LLMProvider = Self.anthropicProviders.contains(config.provider) ? .anthropic : .openai let mcpServers = config.mcpEntries?.mapValues { entry in McpServerConfig.stdio(McpStdioConfig( command: entry.command, args: entry.args, env: entry.env )) } // 始终包含 core + specialist 工具,确保无 MCP 时也有基本能力 let coreTools = getAllBaseTools(tier: .core) + getAllBaseTools(tier: .specialist) return OpenAgentSDK.createAgent(options: AgentOptions( apiKey: config.apiKey, model: config.model, baseURL: config.baseURL, provider: provider, permissionMode: .bypassPermissions, cwd: config.projectDirectory, tools: coreTools, mcpServers: mcpServers, sessionStore: sessionStore, sessionId: sessionId, skillDirectories: config.skillDirectories, logLevel: config.debugMode ? .debug : .none, env: config.env )) }MCP 服务器配置的 UI 数据结构。用户可以添加自定义 stdio 服务器,保存到UserDefaults:
// CustomMcpServerConfig.swift struct CustomMcpServerConfig: Codable, Identifiable { let id: UUID var name: String var command: String var args: [String] var env: [String: String] var enabled: Bool }PATH 扩展的片段。这是 macOS GUI 应用最容易踩的坑,GUI 应用不继承 shell 环境,MCP 子进程找不到node、python。你需要在构建 MCP 服务器配置时手动注入扩展 PATH:
// SDKBridge.swift let extendedPath = configManager.buildExtendedPath( base: ProcessInfo.processInfo.environment["PATH"] ) for entry in mcpEntries { var mergedEnv = entry.env ?? [:] mergedEnv["PATH"] = extendedPath // ... 用 mergedEnv 构建 McpStdioConfig }buildExtendedPath的实现思路是把常见的包管理器路径拼进去,比如/opt/homebrew/bin、/usr/local/bin、~/.nvm/versions/node/*/bin。你可以用FileManager遍历这些目录,把存在的路径拼接到原 PATH 前面。
4. 验证请求:端到端调用与成功结果
配置写完之后,最重要的一步是端到端验证。不要等到 UI 全部接完再测,先在桥接层写一个最小的验证入口。我的做法是在SDKBridge里加一个verifyConnection()方法,直接发一个最简单的 prompt,看能不能拿到流式响应。
验证方法的核心逻辑是:创建 Agent,调用stream(),消费AsyncStream<SDKMessage>,把每条消息打印出来。如果你能看到partialMessage和result两种消息,说明通道是通的:
// SDKBridge.swift func verifyConnection() async throws -> String { guard let config = configuration else { throw BridgeError.notConfigured } let agent = createAgent(from: config, sessionId: UUID().uuidString) var collected = "" for await message in agent.stream("回复一个字:好") { switch message { case .partialMessage(let data): collected += data.text print("[partial] \(data.text)") case .result(let data): print("[result] finish=\(data.finishReason ?? "unknown")") case .error(let err): print("[error] \(err.message)") throw BridgeError.streamFailed(err.message) default: break } } return collected }在 SwiftUI 里挂一个按钮触发这个验证。注意要在Task里调用,因为verifyConnection是 async 的:
// SettingsView.swift Button("验证 TaoToken 通道") { Task { do { let result = try await bridge.verifyConnection() await MainActor.run { statusText = "通道正常,返回:\(result)" } } catch { await MainActor.run { statusText = "验证失败:\(error.localizedDescription)" } } } }成功的结果长这样:控制台先打印一串[partial]片段,最后打印[result] finish=stop,UI 上的状态文字显示「通道正常,返回:好」。如果模型返回的是流式片段,你会看到[partial]逐字出现,这说明流式通道工作正常。
验证的时候有几个观察点。第一,看baseURL有没有被正确拼接。如果报 404,大概率是baseURL多带了/v1。第二,看apiKey有没有被正确注入。如果报 401,检查环境变量名有没有拼错,或者 Key 有没有过期。第三,看模型 ID 有没有写对。如果报 model not found,去模型对话页面确认一下这个模型 ID 在当前账号下是否可用。
我实测下来,从点击按钮到看到第一个[partial]片段,延迟在几百毫秒级别。这比外部进程冷启动的 2 到 5 秒快了一个数量级。而且因为是在进程内,你可以在handleSDKMessage里直接打断点,看每条消息的原始结构,调试体验完全不一样。
验证通过之后,再把submitIntent接到 UI 上。submitIntent的核心是用Task包裹stream()的for await循环,这样用户中断时可以 cancel 掉这个 Task。注意 SDK 里可能也有一个叫Task的类型,跟 Swift 并发的Task冲突,用 typealias 解决:
// SDKBridge.swift private typealias _Task = _Concurrency.Task func submitIntent(text: String, cwd: String, forceNewSession: Bool = false) async { await configureBridge() guard let config = configuration else { eventContinuation.yield(OpenCodeEvent(kind: .error, text: "SDK bridge not configured")) return } let sessionId = forceNewSession ? UUID().uuidString : (currentSessionId ?? UUID().uuidString) currentSessionId = sessionId let sdkAgent = createAgent(from: config, sessionId: sessionId) self.agent = sdkAgent streamTask?.cancel() streamTask = _Task { [weak self] in guard let self else { return } for await message in sdkAgent.stream(text) { guard !_Task.isCancelled else { return } await self.handleSDKMessage(message, sessionId: sessionId) } } }handleSDKMessage负责把 SDK 消息映射成应用已有的事件类型。这样 UI 层完全不用改,它消费的还是同一套事件流:
// SDKBridge.swift private func handleSDKMessage(_ message: SDKMessage, sessionId: String) { switch message { case .partialMessage(let data): eventContinuation.yield(OpenCodeEvent(kind: .assistant, text: data.text)) case .toolUse(let data): eventContinuation.yield(OpenCodeEvent( kind: .tool, text: data.input, toolName: data.toolName, toolCallId: data.toolUseId )) case .toolResult(let data): let output = data.isError ? "Error: \(data.content)" : data.content eventContinuation.yield(OpenCodeEvent( kind: .tool, text: "", toolName: "Result", toolOutput: output, toolCallId: data.toolUseId )) case .result(let data): // 映射 usage、finish、error break default: break } }5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错来。我把集成过程中遇到的和社区里高频出现的错误整理成对照表,每条都给出定位思路和修复动作。
| 报错关键词 | 典型原因 | 修复动作 |
|---|---|---|
| 401 Unauthorized | Key 未注入或已失效 | 检查环境变量名,确认 Key 前缀,重新在控制台创建 |
| local proxy failed | Base URL 不可达或路径拼接错误 | 确认baseURL到域名级别,不带/v1 |
| reading choices | 响应格式与 provider 不匹配 | 确认 provider 映射正确,Anthropic 走 messages,OpenAI 走 chat/completions |
| OAuth / auth.json | 用了需要 OAuth 的 provider 但没配 | 改用 API Key 通道,或补全 auth.json |
| model not found | 模型 ID 写错或账号无权限 | 去模型对话页面确认模型 ID |
| MCP 子进程找不到 node | GUI 应用 PATH 不完整 | 注入扩展 PATH,见第 3 节片段 |
| SDK bridge not configured | 配置未完成就发 prompt | 在 submitIntent 前调用 configureBridge() |
| Task 命名冲突 | SDK 的 Task 与 Swift 并发 Task 撞名 | 用 typealias _Task = _Concurrency.Task |
先说 401。这是最常见的。macOS GUI 应用不继承 shell 环境,你在终端里export的变量,应用启动时读不到。所以如果你在终端里测通了,打包成.app之后报 401,大概率是环境变量没注入。解决方式是在 Xcode Scheme 里配 Environment Variables,或者用设置界面写入UserDefaults。另外检查 Key 有没有多余的空格,复制粘贴时很容易带上换行符。
再说 local proxy failed。这个报错通常出现在baseURL配置错误的时候。如果你把baseURL填成了https://taotoken.net/api/v1,SDK 再拼/v1/messages,就变成了/api/v1/v1/messages,服务端返回 404 或者连接被拒。正确的填法是https://taotoken.net/api,让 SDK 自己拼路径。如果你用的是 OpenAI 兼容协议,同理,不要自己带/v1。
reading choices 这个报错,通常是因为 provider 映射错了。choices是 OpenAI 兼容格式的响应字段,如果你用 Anthropic 协议发请求,但 provider 映射成了.openai,SDK 会按 OpenAI 格式解析响应,自然读不到choices。反过来也一样。检查你的anthropicProviders集合,确认设置界面里选的 provider 字符串在集合里。
OAuth 和 auth.json 的问题,通常出现在你用了需要 OAuth 的 provider,但没有配置对应的认证文件。如果你走的是 API Key 通道,就不需要 auth.json。确认你的AgentOptions里apiKey有值,且 provider 映射正确。如果你确实需要 auth.json,确认它的路径和格式符合 SDK 文档要求。
MCP 子进程找不到 node 或 python,是 macOS GUI 应用的经典问题。GUI 应用的 PATH 通常只有/usr/bin:/bin:/usr/sbin:/sbin,没有 homebrew 的/opt/homebrew/bin,也没有 nvm 的路径。解决方式是在构建 MCP 服务器配置时,手动把扩展 PATH 注入到子进程的环境变量里。这个在第 3 节有完整片段。
SDK bridge not configured 这个错误,是因为配置是异步完成的,用户可能在配置完成之前就发了 prompt。修复方式是在每次submitIntent和resumeSession之前都调用configureBridge(),确保配置是最新的。如果配置失败,configuration会是 nil,这时候要给出明确的错误提示,而不是静默失败。
Task 命名冲突这个坑比较隐蔽。SDK 里可能有一个叫Task的类型,跟 Swift 并发的Task撞名。直接写Task { }编译器会找错类型,报一些莫名其妙的错误。用private typealias _Task = _Concurrency.Task解决,然后所有地方用_Task { }。
还有一个坑是核心工具在无 MCP 时不加载。SDK 的assembleFullToolPool()在没有 MCP 服务器时走了一条短路径,只返回baseTools,不包含内置的 Core 和 Specialist 工具。这意味着如果不配 MCP,Agent 连 Read、Write、Bash 都没有。修复方式是在createAgent()里始终传入 core + specialist 工具,见第 3 节片段。
6. 把通道固定下来:从验证到长期编码
验证通过之后,下一步是把这套通道固定下来,让它能支撑长期的编码类 Agent 任务。这里有几个工程化的建议。
第一,把配置持久化。不要每次启动都让用户重新填 Key。用UserDefaults或者 Keychain 存配置,Key 建议存 Keychain,其他配置存UserDefaults。读取的时候先读环境变量,读不到再读持久化配置,这样开发期和运行期都能工作。
第二,把 provider 列表和模型 ID 做成可配置的。不要硬编码在代码里。你可以从 TaoToken 的模型对话页面确认可用模型,然后在设置界面里做成下拉选择。这样换模型不需要改代码。
第三,把验证入口保留下来。不要验证通过就把verifyConnection()删掉。把它放在设置界面的「诊断」区域,用户遇到问题时可以一键验证通道。这比让用户看日志友好得多。
第四,如果你打算长期跑编码类 Agent,关注一下 Coding Plan 页面。它针对的是高频、长时间的编码场景,比按量计费更适合持续运行的 Agent。你可以先在模型对话页面试几个模型,确认哪个模型在你的任务上表现最好,再决定用哪个。
第五,把 MCP 服务器的配置做成可导入导出的。用户配好一套 MCP 服务器之后,应该能导出成 JSON,换机器时直接导入。这个 JSON 的结构就是第 3 节的CustomMcpServerConfig数组。
最后说下我踩过的一个坑:不要在生产环境用permissionMode: .bypassPermissions跑不受信任的 MCP 服务器。这个模式会让 Agent 自动执行所有工具调用,包括文件写入和命令执行。如果你的 MCP 服务器来自第三方,建议先用更严格的权限模式,确认行为符合预期之后再放开。桌面应用的自动执行场景确实需要 bypass,但前提是你信任这些工具。
整套改造下来,净增约 600 行代码,换来的是去掉了对外部二进制的依赖,启动延迟从秒级降到毫秒级,调试从跨进程日志变成 Xcode 断点。SDK 的createAgent加stream两个调用,替代了原来启动外部进程、HTTP 服务、SSE 客户端、REST API 客户端四个组件。对于一个 361 行的 actor 来说,这个替换比是合理的。