从零手写 Agent 循环:Swift 里的 Tool Use

不用 SDK、不用框架:约 100 行 Swift、五个真实工具,以及揭示 agent 循环本质的原始 HTTP 报文。外加五个会咬你的坑,和一次把未缓存输入从 7,534 个 token 降到 6 个的 prompt caching 实测。

系列:成为 Agent 工程师

  1. 从 iOS 开发者到 AI Agent 工程师
  2. 从零手写 Agent 循环:Swift 里的 Tool Use(本文)
  3. 用 Swift 写一个 MCP server(待发布)
  4. 手写 agent 循环 vs Claude Agent SDK(待发布)
  5. 生产环境中 agent 的 evals 与成本控制(待发布)

为什么要手写这个循环

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_ratingApp 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 回合的请求里,systemtools 和第 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
}

就这么多。所有有意思的东西都在下面这五个细节里。

五个会咬你的坑

  1. 同一回合的所有结果必须合并进一条 user 消息。如果模型请求了三个工具、你却发回三条独立的 user 消息,它照样能跑——然后模型会在后续回合里悄悄不再做并行调用,因为你回给它的形状和它请求的形状不一致。一条消息,N 个 tool_result 块。
  2. assistant 回合要原样回显。不是 completion.text,而是整个 content 数组,包括 tool_use 块和(在思考模型上)thinking 块。删掉或改动任何一个,下一次请求就会被 API 拒绝:你发过去的 tool_result 里的 tool_use_id 找不到对应的调用。这就是我的 complete() 同时返回解析后字段和一个 rawContent 数组的原因。
  3. 预算不是可选项。停不停由模型决定,也就意味着它可能不停。两道天花板:最大轮数和最大工具调用次数。而且工具预算耗尽时要告诉模型,而不是从中间掐断——上面那句注入的提示会让它收尾并说明哪些没查完,而不是给你一个自信但不完整的答案。
  4. 裁剪工具输出。每一条工具结果都会进上下文窗口,并在之后每一回合被重发。一条很长的评论正文就能把别的东西挤出去。我把自由文本字段截到 500 字符。这是上下文工程的廉价版;真正的版本要等到数据根本塞不进去的时候。
  5. 工具报错是结果,不是异常。失败的工具返回一个带 "is_error": truetool_result,模型会自己调整。如果你直接抛异常把循环打死,就把一个可恢复的情况换成了一次崩溃。我的 execute() 捕获一切并做转换。

模型做了、而我的代码没写的事

三个来自真实运行的片段,没有一个是我编排的。正是它们让我确信:workflowagent 的区别不是学术问题。

它自己升级了查询范围。我问两个 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_summaryget_subscriptions 之所以存在,就是因为 agent 当着我的面撞了墙、并且说出来了。

Prompt caching:让我意外的那个数字

每一份 trace 里 cache_read_input_tokens 都是 0。当然了——我根本没实现缓存。由于渲染顺序是 toolssystemmessages,两个 cache_control 断点就能覆盖几乎全部:一个打在 system 块尾(缓存住 tools + system 这个固定前缀),一个打在最后一条 message 的最后一个 block 上(缓存住到目前为止的对话)。

同一个问题,跑两次:

未缓存输入输出缓存命中写入缓存
缓存关闭7,5341,12100
缓存开启61,0984,1123,355

未缓存输入从 7,534 个 token 降到了 6 个。缓存命中按输入价格的约十分之一计费、写入按 1.25× 计费,所以这不是 99% 的成本下降——但同一次运行的输入账单大约变成了原来的四分之一。

两点我想强调:

一个 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 究竟要花多少》(英文)中展开。