










本文是「深入 Open Agent SDK (Swift)」系列第一篇。
大多数 LLM 封装库做的事情是:发请求、拿响应、结束。但一个真正的 Agent 不止于此——它要能自己判断需不需要调工具、执行完工具后把结果喂回 LLM、循环往复直到拿到最终答案。这个循环就是 Agent Loop。
这篇文章分析 Open Agent SDK (Swift) 的 Agent Loop 实现,看它怎样用原生 Swift 并发在进程内跑完一整套循环。
用一句话概括:用户发 prompt → LLM 返回响应 → 如果 LLM 要求调工具就执行 → 把工具结果喂回 LLM → 重复,直到 LLM 说"我说完了"。
画成流程图:
flowchart TD A["用户 prompt"] --> B["构建 messages + tools"] B --> C["调用 LLM API"] C -->|end_turn / stop_sequence| D["返回结果"] C -->|max_tokens| C2["追加'请继续'"] C2 --> C C -->|tool_use| E["提取 tool_use blocks"] E --> F["按只读/变更分桶"] F --> G["只读工具并发执行"] F --> H["变更工具串行执行"] G --> I["微压缩大结果"] H --> I I --> J["tool_result 加入 messages"] J --> C
这个循环里有几个关键决策点:
end_turn 或 stop_sequence 时正常结束;到达 maxTurns 上限时强制停止;超出预算 (maxBudgetUsd) 时中断;用户主动取消时也中断。SDK 提供两种方式触发 Agent Loop:
let agent = createAgent(options: AgentOptions(
apiKey: "sk-...",
model: "claude-sonnet-4-6",
maxTurns: 10
))
let result = await agent.prompt("Read Package.swift and summarize it.")
print(result.text)
print("Turns: \(result.numTurns), Cost: $\(String(format: "%.4f", result.totalCostUsd))")
prompt() 是"发出去等结果"模式。一次调用跑完所有轮次,返回最终的 QueryResult。适合不需要实时看到中间过程的场景——比如后台任务、CLI 工具。
for await message in agent.stream("Explain this codebase.") {
switch message {
case .partialMessage(let data):
print(data.text, terminator: "") // 实时输出文本
case .toolUse(let data):
print("[Using tool: \(data.toolName)]")
case .toolResult(let data):
print("[Tool done, \(data.content.count) chars]")
case .result(let data):
print("\nDone: \(data.numTurns) turns, $\(String(format: "%.4f", data.totalCostUsd))")
default:
break
}
}
stream() 返回 AsyncStream<SDKMessage>,在 LLM 处理过程中持续推送事件。SDK 定义了 17 种消息类型,从 partialMessage(文本片段)到 toolUse(工具调用)到 result(最终结果),覆盖了 Agent Loop 的每个阶段。
选择哪种取决于你的 UI 需求:要实时展示就用 stream(),不需要就用 prompt()。
不管走哪条入口,每个 turn 的核心逻辑是相同的。让我们跟一遍代码。
if shouldAutoCompact(messages: messages, model: model, state: compactState) {
let (newMessages, _, newState) = await compactConversation(
client: client, model: model,
messages: messages, state: compactState,
fileCache: fileCache,
sessionMemory: sessionMemory
)
messages = newMessages
compactState = newState
}
每个 turn 开始前先检查:消息历史估计的 token 数是不是快要撑爆上下文窗口了。如果是,用一个 LLM 调用把历史压缩成摘要,替换掉原始消息。
压缩的阈值是 模型上下文窗口 - 10000 tokens(缓冲区)。连续压缩失败 3 次后会停止尝试,避免浪费 token。
response = try await withRetry({
try await client.sendMessage(
model: model, messages: messages,
maxTokens: maxTokens, system: buildSystemPrompt(),
tools: apiTools, ...
)
}, retryConfig: retryConfig)
所有 LLM 请求都经过 withRetry 包装,按配置的重试策略处理临时错误(网络超时、429 限流等)。
如果主模型彻底失败,还配置了 fallbackModel,SDK 会用备用模型再试一次:
if let fallbackModel = self.options.fallbackModel, fallbackModel != self.model {
// 用 fallbackModel 重试...
}
LLM 响应里的 stop_reason 决定了循环的走向:
| stop_reason | 含义 | 循环行为 |
|---|---|---|
end_turn |
LLM 说完了 | 正常退出循环 |
stop_sequence |
碰到停止符 | 正常退出循环 |
tool_use |
LLM 想调工具 | 执行工具,继续循环 |
max_tokens |
输出被截断 | 追加"请继续",继续循环 |
max_tokens 的情况有个保护:最多自动续接 3 次,防止无限循环。
当 LLM 返回 tool_use 时,SDK 不是简单地把工具排着队一个个跑,而是做了分桶:
// ToolExecutor.partitionTools()
for block in blocks {
let tool = tools.first { $0.name == block.name }
if let tool = tool, tool.isReadOnly {
readOnly.append(item) // 只读桶
} else {
mutations.append(item) // 变更桶
}
}
只读工具(Read、Glob、Grep、WebSearch 等)可以安全并发,用 TaskGroup 跑,最多 10 个一批:
let batchResults = await withTaskGroup(of: ToolResult.self) { group in
for item in batchSlice {
group.addTask {
await executeSingleTool(block: item.block, tool: item.tool, context: ...)
}
}
// 收集结果
}
变更工具(Write、Edit、Bash 等)必须串行执行,一个跑完再跑下一个,避免并发写冲突:
for item in items {
let result = await executeSingleTool(...)
results.append(result)
}
执行顺序:先跑所有只读工具(并发),再跑所有变更工具(串行)。这在 LLM 一次返回多个工具调用时能显著提升性能——比如 LLM 同时要求读 5 个文件,5 个读操作并行完成。
工具执行完后,结果在喂回 LLM 之前还要过一道微压缩:
for result in toolResults {
let processedContent = await processToolResult(result.content, isError: result.isError)
processedResults.append(ToolResult(
toolUseId: result.toolUseId,
content: processedContent,
isError: result.isError
))
}
如果一个工具返回的内容超过 50000 字符(比如读了一个大文件),SDK 会用一次额外的 LLM 调用把内容压缩。错误结果不压缩——保留了完整的错误信息供 LLM 诊断。
每一轮 LLM 调用后,SDK 都会更新 token 用量和费用:
let turnCost = estimateCost(model: model, usage: turnUsage)
totalCostUsd += turnCost
costByModel[model] = CostBreakdownEntry(
model: model,
inputTokens: turnUsage.inputTokens,
outputTokens: turnUsage.outputTokens,
costUsd: turnCost
)
costByModel 按 model 分组记录。这意味着如果你中途切换了模型(通过 switchModel()),每个模型的费用是分开计算的。最终 result.costBreakdown 能告诉你每个模型花了多少钱。
预算检查在每个 turn 后执行:
if let budget = options.maxBudgetUsd, totalCostUsd > budget {
status = .errorMaxBudgetUsd
break
}
超出预算时立即退出循环,但已产生的文本会保留在结果里——你拿到的是部分结果,不是空白的。
Swift 的结构化并发用 Task.isCancelled 做协作式取消。SDK 在循环的多个检查点都检查了这个标志:
// 循环入口
if Task.isCancelled || _interrupted {
status = .cancelled
break
}
// 只读/变更之间
if Task.isCancelled { return results }
stream() 还额外支持通过 interrupt() 方法取消——内部就是 cancel 掉持有 stream 的 Task。
取消后返回的是 QueryResult(isCancelled: true),附带截止到取消时刻的部分文本和 token 用量。
SDK 的错误处理原则是:工具执行错误不传播,API 错误有重试,最终失败保留部分结果。
工具执行时,任何错误都被捕获为 ToolResult(isError: true):
static func executeSingleTool(...) async -> ToolResult {
guard let tool = tool else {
return ToolResult(toolUseId: block.id, content: "Error: Unknown tool", isError: true)
}
// ... try executing
let result = await tool.call(input: block.input, context: context)
return ToolResult(toolUseId: block.id, content: result.content, isError: result.isError)
}
工具报错的结果照样喂回 LLM,LLM 看到错误信息后可以决定换个策略。Agent Loop 不会因为一个工具挂了就崩溃。
API 层面的错误(网络问题、500 等)会触发重试;重试失败后触发 fallback 模型;全挂了才返回 errorDuringExecution 状态。
Agent Loop 在关键节点触发 Hook 事件:
| Hook 事件 | 触发时机 |
|---|---|
sessionStart |
循环开始前 |
preToolUse |
每个工具执行前 |
postToolUse |
工具成功执行后 |
postToolUseFailure |
工具执行失败后 |
stop |
循环结束时(正常或异常) |
sessionEnd |
返回结果前 |
Hook 的一个典型用法是在 preToolUse 拦截危险操作:
await hookRegistry.register(.preToolUse, definition: HookDefinition(
matcher: "Bash",
handler: { input in
return HookOutput(message: "Bash blocked in production", block: true)
}
))
被 Hook 拦截的工具不会执行,而是返回一个错误结果——LLM 会看到"Bash blocked in production",可以换个方式完成任务。
除了 prompt() 和 stream(),SDK 还提供了第三种入口——streamInput(),接受一个 AsyncStream<String> 作为输入:
let input = AsyncStream<String> { continuation in
continuation.yield("What's in this project?")
continuation.yield("Now explain the test structure.")
continuation.finish()
}
for await message in agent.streamInput(input) {
// 处理每条输入对应的响应
}
每个输入元素被视为一条新的用户消息,触发一个完整的 prompt 周期。这适合聊天式交互:用户的每条消息都是输入流的一个元素,Agent 逐条处理并流式输出。
Agent Loop 是整个 SDK 的心脏。理解了它的工作方式,剩下的功能都是在它的基础上叠加的:
下一篇我们深入 工具系统:34 个内置工具怎么组织、ToolProtocol 协议的设计思路、以及怎么用 defineTool 创建自定义工具。
GitHub:terryso/open-agent-sdk-swift
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。