为什么要手写这个循环
Anthropic 的 SDK 自带 tool runner,会替你跑完"请求 → 执行 → 循环"这套流程。生产环境里你大概应该用它。我还是手写了一遍,原因只有一个:我想至少亲眼看一次网络上的原始格式。
这件事的价值超出我的预期。后面所有的决策——怎么管理上下文、缓存断点该打在哪、为什么并行工具调用有时会悄悄消失——只有在你知道实际发出去的是什么之后才讲得通。这篇文章就是我当初希望读到的那份走查:真实系统、真实数据、真实字节。
语言是 Swift,这需要一句解释。Anthropic 没有官方的 Swift SDK,所以"不用 SDK"不是一个选择,而是唯一的选项。结果这反倒成了优点:如果你是 iOS 开发者,你可以不离开自己的语言就构建出一个完整的 agent,而概念可以原封不动迁移到 TypeScript 或 Python——网络上的格式在任何语言里都是同一份 JSON。
它建在什么之上
我有一个 macOS 小工具,通过 App Store Connect API 读我自己那些 app 的数据——评论、评分、销量、订阅。这给了我大多数练习项目没有的东西:真实数据之上的真实工具。这篇文章里 agent 有五个,全部只读:
| 工具 | 返回什么 |
|---|---|
list_apps | 账号下所有 app:id、名称、bundle id |
get_reviews | 最新文字评论,含星级、地区、是否已回复 |
get_store_rating | App Store 真实综合星级(公开 iTunes 接口) |
get_sales_summary | 最近 N 天的下载、内购笔数、收入 |
get_subscriptions | 最新快照:活跃订阅、免费试用、预估 MRR |
每一个都是薄封装:JSON Schema 定义、参数提取、调用已有的服务层、裁剪输出。工具本体不含业务逻辑。这个切分在后面会显出价值——同一批工具会被第二次暴露出去(通过 MCP),而它们本身一行都不用改。
关于原始格式的三个事实
我加了一个 ANTHROPIC_TRACE=<文件> 开关,把每一回合的 request 和 response body 完整落盘。下面所有内容都是从一次真实运行里复制出来的——问题是"Fed? 最近有没有差评?",模型 claude-haiku-4-5,跑了三个回合。
事实 1:工具定义 = 名字 + 描述 + JSON Schema
"tools": [
{
"name": "list_apps",
"description": "列出该开发者账号下的所有 App(app_id、名称、bundle_id)。
当问题涉及某个 App 但你还不知道它的 app_id 或 bundle_id 时,先调用它。",
"input_schema": { "type": "object", "properties": {}, "required": [] }
},
…
]
把那句描述再读一遍。它说的不是这个工具是什么,而是什么时候该调用它。这一句话是整个系统里杠杆最大的一行,下一回合就能看到原因。
事实 2:模型用 tool_use 块回答,并给出 stop_reason: "tool_use"
"content": [
{ "type": "text",
"text": "我需要先查看你账号下的所有 App,确认\"Fed\"是哪个应用…" },
{ "type": "tool_use", "id": "toolu_01C5uN44…",
"name": "list_apps", "input": {} }
],
"stop_reason": "tool_use",
"usage": { "input_tokens": 1989, "output_tokens": 77,
"cache_read_input_tokens": 0 }
两点值得停一下。文字和工具调用在同一个 content 数组里——模型一边解释一边动手,不是二选一。而且它没有猜 Fed? 的 app id,而是先调了 list_apps,正如描述里要求的那样。工具描述就是提示工程,这就是它生效时的样子。
stop_reason == "tool_use" 就是你循环的判断条件。
事实 3:把结果喂回去,就是往 messages 里追加
这是我在读 trace 之前理解错的地方。第 2 回合的请求里,system 和 tools 和第 1 回合完全一样;唯一的区别是 messages 从 1 条变成了 3 条:
"messages": [
{ "role": "user", "content": "Fed? 最近有没有差评?" },
{ "role": "assistant", "content": [ …第 1 回合响应的原样回显… ] },
{ "role": "user", "content": [ {
"type": "tool_result",
"tool_use_id": "toolu_01C5uN44…",
"content": "[{\"app_id\":\"6760254340\",\"name\":\"Second Brain…\"},
… 中间还有 17 个 app …,
{\"app_id\":\"6760776848\",\"name\":\"Fed? Pet Feeding…\"}]"
} ] }
]
这就是全部机制。一个 agent 循环,就是每轮往 messages 里追加两条消息、然后重发的 while 循环。没有隐藏状态、没有会话、服务端不记任何东西——API 是无状态的,每次都要把全部内容重发一遍。这也正是文末成本那一节重要的原因。
注意工具结果是字符串,不是嵌套的 JSON 对象。你的工具返回什么就序列化成什么,模型自己解析。
也注意这个字符串装了什么:list_apps 不接受任何参数,所以它返回账号下的全部 app——一共 19 个,按账号里的顺序排,所以数组开头是一个和问题无关的 app。模型会在下一回合自己挑出需要的那条(这里是 Fed?)。把一个需要模型自己过滤的列表丢给它,19 条时没问题,200 条时就是设计缺陷——文末会回到这个话题。
循环本体
有了这三个事实,循环几乎是自己写出来的:
public func run(_ question: String) async throws -> String {
var messages: [[String: Any]] = [["role": "user", "content": question]]
var toolCallCount = 0
var finalText = ""
for _ in 0..<budget.maxTurns {
let completion = try await client.complete(
model: model, system: system, messages: messages,
tools: tools.map(\.spec))
if !completion.text.isEmpty { finalText = completion.text }
// assistant 回合必须原样回显。
messages.append(["role": "assistant", "content": completion.rawContent])
guard completion.stopReason == "tool_use",
!completion.toolCalls.isEmpty else {
return finalText
}
// 同一回合的所有结果,必须合并进同一条 user 消息。
var userContent: [[String: Any]] = []
for call in completion.toolCalls {
toolCallCount += 1
let (output, isError) = await execute(call)
var block: [String: Any] = [
"type": "tool_result",
"tool_use_id": call.id,
"content": output,
]
if isError { block["is_error"] = true }
userContent.append(block)
}
if toolCallCount >= budget.maxToolCalls {
userContent.append(["type": "text", "text":
"(系统提示:工具调用预算已用尽。请基于已获得的信息直接作答,"
+ "并明确说明哪些检查未能完成。)"])
}
messages.append(["role": "user", "content": userContent])
}
return finalText
}
就这么多。所有有意思的东西都在下面这五个细节里。
五个会咬你的坑
- 同一回合的所有结果必须合并进一条 user 消息。如果模型请求了三个工具、你却发回三条独立的
user消息,它照样能跑——然后模型会在后续回合里悄悄不再做并行调用,因为你回给它的形状和它请求的形状不一致。一条消息,N 个tool_result块。 - assistant 回合要原样回显。不是
completion.text,而是整个content数组,包括tool_use块和(在思考模型上)thinking 块。删掉或改动任何一个,下一次请求就会被 API 拒绝:你发过去的tool_result里的tool_use_id找不到对应的调用。这就是我的complete()同时返回解析后字段和一个rawContent数组的原因。 - 预算不是可选项。停不停由模型决定,也就意味着它可能不停。两道天花板:最大轮数和最大工具调用次数。而且工具预算耗尽时要告诉模型,而不是从中间掐断——上面那句注入的提示会让它收尾并说明哪些没查完,而不是给你一个自信但不完整的答案。
- 裁剪工具输出。每一条工具结果都会进上下文窗口,并在之后每一回合被重发。一条很长的评论正文就能把别的东西挤出去。我把自由文本字段截到 500 字符。这是上下文工程的廉价版;真正的版本要等到数据根本塞不进去的时候。
- 工具报错是结果,不是异常。失败的工具返回一个带
"is_error": true的tool_result,模型会自己调整。如果你直接抛异常把循环打死,就把一个可恢复的情况换成了一次崩溃。我的execute()捕获一切并做转换。
模型做了、而我的代码没写的事
三个来自真实运行的片段,没有一个是我编排的。正是它们让我确信:workflow 和 agent 的区别不是学术问题。
它自己升级了查询范围。我问两个 app 哪个评分更高。它调了 list_apps,然后对两个都调 get_store_rating——结果美区两个都是 rating_count: 0。它没有回报"没数据",而是转头去查了日本、中国、英国、德国,在其中一个里找到了唯一一条评分,然后明确告诉我样本太小、没法比较。我的代码里没有任何一条规则说"某个市场为空时该去查哪些市场"。
它先验证再执行。我故意给了它一个错的 app id:"Fed? 的 app_id 是 1234567890,帮我查一下它最近的评论"。它没有拿这个错 id 去调 get_reviews,而是先调 list_apps,发现不匹配,纠正我,然后用真实的 id 继续。注意这是什么:不是错误恢复,是错误规避。一个会拿用户给的标识符去和基准事实核对再行动的模型,和一个失败后重试的模型,是两种不同的东西。
它拒绝猜。在我还没有销售工具的时候,我问了订阅情况。它先确认 app 存在,然后直说订阅数据超出了它的工具范围,列出自己能看到的东西,拒绝给出任何数字。我的 system prompt 里为此只有一句话:只依据工具返回的真实数据作答,不知道就说不知道。这句话是"助手"和"负债"之间的分界线。它同时也是我 eval 集里的第一条样本。
一个值得点明的副作用:狗粮会告诉你该建哪些工具。get_sales_summary 和 get_subscriptions 之所以存在,就是因为 agent 当着我的面撞了墙、并且说出来了。
Prompt caching:让我意外的那个数字
每一份 trace 里 cache_read_input_tokens 都是 0。当然了——我根本没实现缓存。由于渲染顺序是 tools → system → messages,两个 cache_control 断点就能覆盖几乎全部:一个打在 system 块尾(缓存住 tools + system 这个固定前缀),一个打在最后一条 message 的最后一个 block 上(缓存住到目前为止的对话)。
同一个问题,跑两次:
| 未缓存输入 | 输出 | 缓存命中 | 写入缓存 | |
|---|---|---|---|---|
| 缓存关闭 | 7,534 | 1,121 | 0 | 0 |
| 缓存开启 | 6 | 1,098 | 4,112 | 3,355 |
未缓存输入从 7,534 个 token 降到了 6 个。缓存命中按输入价格的约十分之一计费、写入按 1.25× 计费,所以这不是 99% 的成本下降——但同一次运行的输入账单大约变成了原来的四分之一。
两点我想强调:
- 省下的主要是对话历史,不是 system prompt。多回合 agent 每一轮都把整份对话记录重发一次,那才是不断膨胀的部分。system 断点是容易拿的那一分,messages 断点才是大头。
- 缓存之所以划算,是因为 agent 会反复重读同一个前缀。单次一发的调用打了缓存断点反而更贵——你付了写入溢价却从没读回来。循环是理想场景,这也是为什么缓存对 agent 比对聊天更重要。
一个 Swift 特有的小坑:cache_control 只存在于 block 形态的 content 上,所以纯字符串 content 必须先被提升成单元素 text block,才能给它盖章。
如果重来我会改什么
list_apps 会返回我账号下全部 19 个 app——包括好几年前就不再维护的项目。大约 1,700 字符的 JSON,每回合重发一次,其中大部分毫不相关。更糟的是,"我哪个 app 评分最高"这样的问题需要调 19 次 get_store_rating,会在答出来之前就撞爆工具预算。
这两个问题都是工具设计问题,不是模型问题。解法是给 list_apps 加一个过滤参数、给评分工具加一个批量版本。这是我反复重学的一课:当 agent 表现不好时,第一个该看的地方是工具面,不是提示词。
下一篇
同样这五个工具现在被第二次暴露了出去——做成 MCP server,让 Claude Code 来跑循环,而不是我自己的循环。做这件事时撞上了两个任何文档都不会警告你的失败模式,其中一个会让进程直接死锁。那是下一篇的内容。
如果你是没有上下文直接读到这里的:这个系列记录的是一个 iOS 开发者转型 agent 工程的过程;而早于这一切的 Claude 集成,写在《在 Swift app 里使用 Claude API》(英文)里。成本这条线继续在《给 iOS app 加 AI 究竟要花多少》(英文)中展开。