多 LLM 提供商与运行时控制

📅 2026/7/2 3:00:51
多 LLM 提供商与运行时控制
些场景甚至需要本地模型。而且运行时的需求也在变化用户可能中途要求更深度的思考可能发现预算快用完了需要降级可能想切换到本地模型省点钱。Open Agent SDK 的做法是定义一个统一的LLMClient协议Anthropic 和 OpenAI 兼容提供商各有一个实现Agent 内部全部用 Anthropic 格式处理。切换提供商只需要改一个配置参数运行时还能动态切模型、调思考深度、控预算。这篇文章分析 SDK 的多提供商适配机制和运行时控制能力。一、LLMClient 协议——统一接口先看协议定义public protocol LLMClient: Sendable { nonisolated func sendMessage( model: String, messages: [[String: Any]], maxTokens: Int, system: String?, tools: [[String: Any]]?, toolChoice: [String: Any]?, thinking: [String: Any]?, temperature: Double? ) async throws - [String: Any] nonisolated func streamMessage( model: String, messages: [[String: Any]], maxTokens: Int, system: String?, tools: [[String: Any]]?, toolChoice: [String: Any]?, thinking: [String: Any]?, temperature: Double? ) async throws - AsyncThrowingStreamSSEEvent, Error }两个核心方法一个阻塞一个流式。参数列表覆盖了主流 LLM API 的全部能力模型选择、消息历史、token 上限、系统提示、工具定义、工具选择策略、思考配置、温度。关键决策返回值统一用 Anthropic 格式的字典。不管是 Anthropic 原生 API 还是 OpenAI 兼容 API最终 Agent 内部拿到的都是同一种结构——content数组里是{type: text, text: ...}或{type: tool_use, name: ..., input: {...}}stop_reason是end_turn/tool_use/max_tokens。这样 Agent Loop 的处理逻辑不需要关心底层是哪家 API。流式返回用AsyncThrowingStreamSSEEvent, ErrorSSEEvent是枚举public enum SSEEvent: unchecked Sendable { case messageStart(message: [String: Any]) case contentBlockStart(index: Int, contentBlock: [String: Any]) case contentBlockDelta(index: Int, delta: [String: Any]) case contentBlockStop(index: Int) case messageDelta(delta: [String: Any], usage: [String: Any]) case messageStop case ping case error(data: [String: Any]) }7 种事件类型覆盖了 Anthropic Messages API 流式响应的全部事件。OpenAI 兼容层的流式输出会被转换成同样的 SSEEvent 序列。二、AnthropicClient——原生 Claude APIAnthropicClient是LLMClient的 Anthropic 原生实现用actor保证并发安全public actor AnthropicClient: LLMClient { private let apiKey: String private let baseURL: URL // 默认 https://api.anthropic.com private let urlSession: URLSession public init(apiKey: String, baseURL: String? nil, urlSession: URLSession? nil) { self.apiKey apiKey self.baseURL URL(string: baseURL ?? https://api.anthropic.com)! self.urlSession urlSession ?? URLSession.shared } }请求就是 POST 到/v1/messagesheader 里放x-api-key和anthropic-versionprivate nonisolated func buildRequest(body: [String: Any]) throws - URLRequest { var request URLRequest(url: URL(string: baseURL.absoluteString /v1/messages)!) request.httpMethod POST request.timeoutInterval 300 request.setValue(apiKey, forHTTPHeaderField: x-api-key) request.setValue(2023-06-01, forHTTPHeaderField: anthropic-version) request.setValue(application/json, forHTTPHeaderField: content-type) request.httpBody try JSONSerialization.data(withJSONObject: body, options: []) return request }因为用的是 Anthropic 原生 API所以sendMessage的请求体和响应体不需要格式转换——请求参数直接拼成字典发出去响应直接解析成字典返回。流式模式也是直接解析 Anthropic 的 SSE 文本。安全方面有个细节所有错误信息都会把 API Key 替换成***防止 key 泄露到日志里let safeMessage errorMessage.replacingOccurrences(of: apiKey, with: ***)AnthropicClient 直接支持 Extended Thinking。Agent 在配置了ThinkingConfig时会把 thinking 参数传进来if let thinking { body[thinking] thinking }这个参数在 Anthropic API 里控制 Claude 是否进行深度思考以及思考的 token 预算。三、OpenAI 兼容层——适配 GLM/Ollama/OpenRouter 等OpenAIClient是重头戏。它要做的事情是接受 Anthropic 格式的参数转换成 OpenAI Chat Completion API 格式发出去再把 OpenAI 格式的响应转换回 Anthropic 格式。Agent 内部完全不知道底层是 OpenAI 兼容 API。public actor OpenAIClient: LLMClient { private let apiKey: String private let baseURL: URL // 默认 https://api.openai.com/v1 public init(apiKey: String, baseURL: String? nil, urlSession: URLSession? nil) { self.apiKey apiKey self.baseURL URL(string: baseURL ?? https://api.openai.com/v1)! self.urlSession urlSession ?? URLSession.shared } }请求发到/chat/completions用Bearertoken 认证——这是 OpenAI 兼容 API 的标准做法。只要提供商支持/v1/chat/completions端点就能用这个 Client 连接。消息格式转换Anthropic 和 OpenAI 的消息格式有几个关键差异转换时都要处理1. System 消息的位置Anthropic 把 system prompt 作为顶层参数传OpenAI 把它作为第一条role: system消息if let system { result.append([role: system, content: system]) }2. Tool Result 的表示方式Anthropic 把多个 tool_result 打包在一个role: user消息的 content 数组里OpenAI 要求每个 tool result 是一条独立的role: tool消息let toolResults blocks.filter { $0[type] as? String tool_result } if !toolResults.isEmpty { return toolResults.map { block in [ role: tool, tool_call_id: block[tool_use_id] as? String ?? , content: block[content] ?? , ] } }3. Tool Use 的表示方式Anthropic 在 content 数组里用type: tool_use块OpenAI 用tool_calls数组放在 message 顶层result[tool_calls] toolUseBlocks.enumerated().map { index, block in let inputDict block[input] as? [String: Any] ?? [:] let arguments (try? JSONSerialization.data(withJSONObject: inputDict, options: [])) .flatMap { String(data: $0, encoding: .utf8) } ?? {} return [ id: block[id] as? String ?? call_\(index), type: function, function: [ name: block[name] as? String ?? , arguments: arguments, // OpenAI 要求 JSON 字符串不是字典 ], ] }注意 OpenAI 的arguments必须是 JSON 字符串而不是字典对象这里做了序列化。响应格式转换OpenAI 的响应结构choices[0].message要转成 Anthropic 格式// stop_reason 映射 private static func mapStopReason(_ finishReason: String) - String { switch finishReason { case stop: return end_turn case tool_calls: return tool_use case length: return max_tokens default: return finishReason } } // usage 映射 usage [ input_tokens: openAIUsage[prompt_tokens] as? Int ?? 0, output_tokens: openAIUsage[completion_tokens] as? Int ?? 0, ]流式转换流式的转换更复杂。OpenAI 的流式格式data: {choices:[{delta:{...}}]}要逐块转成 Anthropic 的 SSEEvent 序列第一个 chunk →messageStart文本 delta →contentBlockDelta(type: text_delta)tool call 开始 →contentBlockStart(type: tool_use)参数 delta →contentBlockDelta(type: input_json_delta)结束 →contentBlockStopmessageDeltamessageStop转换函数要跟踪当前有多少个 content block、文本块是否关闭、哪些 tool call 块还在打开状态才能正确生成index。代码里还加了一个安全检查——确保messageStop一定会被发出即使原始流没有正常结束。使用示例连接不同的 OpenAI 兼容提供商只需要改baseURL和model// DeepSeek let agent createAgent(options: AgentOptions( apiKey: sk-..., model: deepseek-chat, baseURL: https://api.deepseek.com/v1, provider: .openai )) // Ollama 本地 let localAgent createAgent(options: AgentOptions( apiKey: ollama, // Ollama 不需要 key随便填 model: qwen3:8b, baseURL: http://localhost:11434/v1, provider: .openai )) // GLM let glmAgent createAgent(options: AgentOptions( apiKey: xxx.glm-xxx, model: glm-4-plus, baseURL: https://open.bigmodel.cn/api/paas/v4, provider: .openai ))四、运行时模型切换SDK 支持在运行时动态切换模型不需要重新创建 Agentlet agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, fallbackModel: claude-haiku-4-5 // 主模型挂了用这个 )) // 先用 sonnet 跑一个简单问题 let result1 await agent.prompt(What is 2 3?) print(result1.costBreakdown) // [CostBreakdownEntry(model: claude-sonnet-4-6, inputTokens: 45, outputTokens: 3, costUsd: 0.000180)] // 切换到 opus 跑推理密集型问题 try agent.switchModel(claude-opus-4-6) let result2 await agent.prompt(Explain the difference between structs and classes in Swift.) print(result2.costBreakdown) // [CostBreakdownEntry(model: claude-opus-4-6, inputTokens: 52, outputTokens: 156, costUsd: 0.011970)]switchModel()的实现public func switchModel(_ model: String) throws { let trimmed model.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw SDKError.invalidConfiguration(Model name cannot be empty) } let oldModel self.model self.model trimmed self.options.model trimmed Logger.shared.info(Agent, model_switch, data: [from: oldModel, to: trimmed]) }不做白名单校验——传什么模型名就用什么API 层面不支持的模型会在请求时报错。这样设计是因为 OpenAI 兼容提供商的模型名无法穷举。fallbackModel是在 AgentOptions 里配置的备用模型。主模型彻底失败重试耗尽后SDK 会自动用 fallback model 重试一次if let fallbackModel self.options.fallbackModel, fallbackModel ! self.model { let fallbackResponse try await retryClient.sendMessage( model: fallbackModel, messages: retryMessages, ... ) // 临时切到 fallback model 跑 cost tracking let originalModel self.model self.model fallbackModel // ... 处理响应 }按模型分别计费CostBreakdownEntry按模型名分组记录每次查询的费用public struct CostBreakdownEntry: Sendable, Equatable { public let model: String public let inputTokens: Int public let outputTokens: Int public let costUsd: Double }一次查询里如果中途切了模型或触发了 fallbackQueryResult.costBreakdown会包含多个条目每个模型的花费分开算。费用根据内置的价格表计算public nonisolated(unsafe) var MODEL_PRICING: [String: ModelPricing] [ claude-opus-4-6: ModelPricing(input: 15.0 / 1_000_000, output: 75.0 / 1_000_000), claude-sonnet-4-6: ModelPricing(input: 3.0 / 1_000_000, output: 15.0 / 1_000_000), claude-haiku-4-5: ModelPricing(input: 0.8 / 1_000_000, output: 4.0 / 1_000_000), // ... ]自定义模型可以通过registerModel(_:pricing:)注册价格registerModel(glm-4-plus, pricing: ModelPricing( input: 0.1 / 1_000_000, output: 0.1 / 1_000_000 ))五、Thinking 与 Effort 配置ThinkingConfigSDK 用ThinkingConfig枚举控制 LLM 的深度思考能力public enum ThinkingConfig: Sendable, Equatable { case adaptive // 模型自己决定要不要思考 case enabled(budgetTokens: Int) // 指定思考的 token 预算 case disabled // 关闭深度思考 }三种模式各有用途adaptive让模型自己判断——简单问题不思考复杂问题自动思考。日常使用最方便。enabled(budgetTokens:)明确控制思考预算。比如你想要深度分析给 10000 个 thinking token。disabled完全关闭思考追求最快速度。EffortLevelEffortLevel是更高层级的抽象映射到具体的 thinking token 预算public enum EffortLevel: String, Sendable, CaseIterable { case low // 1024 tokens case medium // 5120 tokens case high // 10240 tokens case max // 32768 tokens public var budgetTokens: Int { switch self { case .low: return 1024 case .medium: return 5120 case .high: return 10240 case .max: return 32768 } } }在AgentOptions里设置let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, effort: .high // 10240 thinking tokens ))运行时动态调节setMaxThinkingTokens()可以在查询之间调整思考预算// 普通问题少给点思考 token try agent.setMaxThinkingTokens(2048) let r1 await agent.prompt(Summarize this file.) // 遇到复杂推理问题加大预算 try agent.setMaxThinkingTokens(16000) let r2 await agent.prompt(Design a concurrent data structure for...) // 关闭思考 try agent.setMaxThinkingTokens(nil)传正整数就启用思考并设预算传nil就关闭。传 0 或负数会抛SDKError.invalidConfiguration。ModelInfo描述了每个模型支持哪些能力public struct ModelInfo: Sendable, Equatable { public let value: String public let displayName: String public let description: String public let supportsEffort: Bool public let supportedEffortLevels: [EffortLevel]? public let supportsAdaptiveThinking: Bool? public let supportsFastMode: Bool? }这样 UI 层可以根据模型能力动态展示可选项。六、Skills 系统Skills 是 SDK 里一种特殊的扩展机制——本质上是带工具限制的 prompt 模板。一个 Skill 定义了一组 prompt 指令、允许使用的工具子集、可选的模型覆盖。Skill 结构public struct Skill: Sendable { public let name: String public let description: String public let aliases: [String] // 别名如 [ci] 代表 commit public let userInvocable: Bool // 用户能否通过 /command 调用 public let toolRestrictions: [ToolRestriction]? // 限制可用工具nil 全部可用 public let modelOverride: String? // 执行时覆盖模型 public let isAvailable: Sendable () - Bool // 运行时可用性检查 public let promptTemplate: String // prompt 模板内容 public let whenToUse: String? // 告诉 LLM 什么时候该用这个 skill public let argumentHint: String? // 参数提示如 [message] public let baseDir: String? // skill 目录的绝对路径 public let supportingFiles: [String] // 支撑文件引用、脚本等 }5 个内置 SkillSDK 预定义了 5 个常用 Skill通过BuiltInSkills命名空间访问Skill别名允许的工具功能commitcibash, read, glob, grep分析 git diff生成 commit messagereviewreview-pr,crbash, read, glob, grep从 5 个维度审查代码变更simplify—bash, read, grep, glob审查代码的复用、质量、效率debuginvestigate,diagnoseread, grep, glob, bash分析错误定位根因testrun-testsbash, read, write, glob, grep生成测试用例并执行每个 Skill 都限制了工具范围。比如commit只允许 bash、read、glob、grep——不需要写文件。debug也是只读的read、grep、glob、bash只做诊断不做修改。test是唯一允许 write 的内置 Skill因为要创建测试文件。testSkill 还有一个运行时可用性检查isAvailable: { let cwd FileManager.default.currentDirectoryPath let testIndicators [ Package.swift, pytest.ini, jest.config, vitest.config, Cargo.toml, go.mod, ] for indicator in testIndicators { if FileManager.default.fileExists(atPath: cwd / indicator) { return true } } return false }只有检测到测试框架配置文件时testSkill 才对用户可见。SkillRegistrySkillRegistry是线程安全的 skill 管理器用DispatchQueue保护并发访问public final class SkillRegistry: unchecked Sendable { private var skills: [String: Skill] [:] private var orderedNames: [String] [] private var aliases: [String: String] [:] private let queue DispatchQueue(label: com.openagentsdk.skillregistry) public func register(_ skill: Skill) { ... } public func find(_ name: String) - Skill? { ... } // 按名称或别名查找 public var allSkills: [Skill] { ... } public var userInvocableSkills: [Skill] { ... } }注册、查找、替换、删除都是queue.sync保护的操作。别名在注册时自动建立映射——注册BuiltInSkills.commit后registry.find(ci)也能找到它。SkillLoader文件系统发现Skills 不需要全部代码注册。SkillLoader可以从文件系统自动发现 skill——只要一个目录里包含SKILL.md文件就会被识别为一个 skill 包。扫描目录按优先级从低到高~/.config/agents/skills 最低优先级 ~/.agents/skills ~/.claude/skills $PWD/.agents/skills $PWD/.claude/skills 最高优先级同名 skill 后发现的覆盖先发现的last-wins。SKILL.md用 YAML frontmatter 定义元数据--- name: polyv-live-cli description: 管理保利威直播服务 aliases: live, plv allowed-tools: Bash, Read, Write, Glob when-to-use: user asks about live streaming management argument-hint: [action] [options] --- # polyv-live-cli Skill 你是保利威直播服务的管理助手...frontmatter 里的allowed-tools会被解析成ToolRestriction数组限制这个 skill 执行时只能用指定的工具。SkillLoader采用渐进式加载策略只加载SKILL.md的 Markdown body 作为 prompt 模板支撑文件references、scripts、templates只记录路径不加载内容。Agent 需要时通过 Read/Bash 工具按需读取。let registry SkillRegistry() registry.register(BuiltInSkills.commit) registry.register(BuiltInSkills.review) // 从文件系统发现自定义 skills let count registry.registerDiscoveredSkills() // 或指定目录 registry.registerDiscoveredSkills(from: [/opt/custom-skills]) // 或只注册白名单里的 registry.registerDiscoveredSkills(skillNames: [polyv-live-cli])ToolRestrictionToolRestriction枚举定义了可以被限制的工具public enum ToolRestriction: String, Sendable, CaseIterable { case bash, read, write, edit, glob, grep case webFetch, webSearch, askUser, toolSearch case agent, sendMessage case taskCreate, taskList, taskUpdate, taskGet, taskStop, taskOutput case teamCreate, teamDelete case notebookEdit, skill }当一个 Skill 设了toolRestrictions: [.bash, .read, .glob]执行时 Agent 只能用这三个工具其他工具调用会被拦截。在 Agent 里使用 Skills要让 Agent 能用 Skills需要把SkillTool加到工具列表里var tools getAllBaseTools(tier: .core) tools.append(createSkillTool(registry: registry)) let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, permissionMode: .bypassPermissions, tools: tools )) // Agent 会根据 system prompt 里的 skill 列表自动发现并调用 let result await agent.prompt(Use the commit skill to analyze current changes)SkillRegistry.formatSkillsForPrompt()会生成一段 skill 列表注入到 system prompt 里包含每个 skill 的名称、描述和触发条件。LLM 看到这个列表后就知道该在什么场景下调用哪个 skill。七、其他运行时控制预算控制maxBudgetUsd设置查询的费用上限let agent createAgent(options: AgentOptions( apiKey: apiKey, model: claude-sonnet-4-6, maxBudgetUsd: 0.05 // 最多花 5 美分 ))每个 turn 结束后检查累计费用if let budget options.maxBudgetUsd, totalCostUsd budget { status .errorMaxBudgetUsd break }超出预算时立即退出循环。已产生的文本和 token 统计仍然保留在QueryResult里——你拿到的是部分结果不是空白的。查询中断两种方式中断正在进行的查询// 方式 1调用 interrupt() agent.interrupt() // 方式 2取消 Task let task Task { await agent.prompt(Long running query...) } // 稍后 task.cancel()interrupt()内部设置了_interrupted标志并取消 stream task。Agent Loop 在多个检查点检查这个标志循环入口、只读/变更工具之间、SSE 事件循环内部、工具执行前后检测到后立即退出。动态权限切换运行时可以切换权限模式和工具授权回调// 切换权限模式 agent.setPermissionMode(.askForPermission) // 设置自定义授权回调优先级高于 permissionMode agent.setCanUseTool { toolName, input in if toolName Bash { return .deny(Bash is disabled) } return .allow } // 恢复到 permissionMode 控制 agent.setCanUseTool(nil)setCanUseTool的回调优先于permissionMode。调setPermissionMode()会清空之前设的回调。环境变量配置SDK 支持通过环境变量配置优先级是代码设置 环境变量 默认值。环境变量对应字段默认值CODEANY_API_KEYapiKeynilCODEANY_MODELmodelclaude-sonnet-4-6CODEANY_BASE_URLbaseURLnil用提供商默认用SDKConfiguration.resolved()合并// 代码设置的值优先没设的从环境变量读 let config SDKConfiguration.resolved(overrides: SDKConfiguration( apiKey: sk-..., // 优先于 CODEANY_API_KEY model: claude-sonnet-4-6 // 优先于 CODEANY_MODEL )) // 只用环境变量 let envConfig SDKConfiguration.fromEnvironment()重试机制所有 LLM 请求经过withRetry包装public struct RetryConfig: Sendable { public let maxRetries: Int // 最多重试次数默认 3 public let baseDelayMs: Int // 基础延迟默认 2000ms public let maxDelayMs: Int // 最大延迟默认 30000ms public let retryableStatusCodes: SetInt // 默认 [429, 500, 502, 503, 529] }指数退避 25% 随机抖动避免惊群效应。只有SDKError.apiError且状态码在可重试集合里才会重试其他错误直接抛出。let delay config.baseDelayMs * (1 attempt) let jitterMs Int(Double(delay) * 0.25 * (Double.random(in: -1...1))) let totalMs max(0, min(delay jitterMs, config.maxDelayMs))系列回顾六篇文章写完了覆盖了 Open Agent SDK (Swift) 的完整架构第 0 篇项目概述——SDK 做什么、整体架构、怎么用