---
url: /agents/agent-loop-minimal.md
description: >-
  用 Anthropic Messages API 手写最小 agent 循环：tools 定义、stop_reason 判断、tool_result
  回填，解释为什么循环能停下，以及三个我踩过的坑。
---

# 不用框架，60 行写一个最小 Agent 循环

## Agent 本体就是一个 while 循环

很多人第一反应是上框架。我一开始也是，装了一堆依赖，结果调试时连模型到底收到了什么 prompt 都看不清。

后来我把项目里的 agent 重写成了 60 行代码，没有依赖，一个 `while` 循环。跑了几个月，没出过框架解决不了的问题。

这篇文章用 Anthropic Messages API（模型名 `claude-sonnet-4-6`，2026-10-07 从官方文档核实）演示这个循环长什么样。

## 先定义工具

模型不执行任何东西，它只是输出一个结构化的调用请求。执行永远在你的代码里。

```python
import anthropic, json

client = anthropic.Anthropic()

tools = [{
    "name": "get_weather",
    "description": "查询某城市当前天气",
    "input_schema": {
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
    },
}]

def get_weather(city: str) -> str:
    return json.dumps({"city": city, "temp_c": 18}, ensure_ascii=False)
```

`description` 不是注释，是给模型看的。我建议写得像人话，别堆关键词。模型选不选这个工具，主要看它。

## 循环本体

```python
def run(query: str, max_turns: int = 10) -> str:
    messages = [{"role": "user", "content": query}]

    for _ in range(max_turns):
        resp = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            tools=tools,
            tool_choice={"type": "auto"},
            messages=messages,
        )
        messages.append({"role": "assistant", "content": resp.content})

        if resp.stop_reason != "tool_use":
            return resp.content[0].text  # 模型说完了

        results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = get_weather(**block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        messages.append({"role": "user", "content": results})
```

核心判断只有一个：`stop_reason` 是不是 `tool_use`。是，就执行工具、把结果按 `tool_result` 块回填，继续循环；不是，模型认为事情办完了，直接返回文本。

`tool_choice` 有三档：`auto` 让模型自己决定、`any` 强制必须调一个、`tool` 强制调指定那一个（这三档的说明 2026-10-07 核对过官方文档）。日常用 `auto` 就够。

## 为什么循环一定能停

三个出口，缺一个都是死循环：

* `stop_reason != "tool_use"`：模型自然收尾，正常退出
* `max_turns`：模型反复调工具停不下来时兜底。我上次调试一个搜索工具，模型连调了 14 轮还没停，就是这个兜底救的命
* API 报错：自己加 try/except，报错后退出而不是让异常炸掉

另一个常被忽略的点是 token 账。工具定义本身占输入 token，Claude 各主流模型每请求固定加 346 tokens（官方文档给的数字，2026-10-07 核实）。工具不多无所谓，堆到几十个工具时这笔账很可观，Anthropic 后来专门出了 Tool Search Tool 来省这笔开销。

## 我踩过的三个坑

第一，`tool_result` 的 `content` 必须是字符串。我直接把 dict 传进去，API 报 400，找了半小时才发现要 `json.dumps`。

第二，工具报错不要吞。数据库查不到就返回 `{"error": "city not found"}` 这种结构化信息给模型，它会自己换姿势重试。返回空字符串，模型会当成"查到了但没内容"，越走越偏。

第三，OpenAI 的 Chat Completions 接口结构不一样：回填用 `role: "tool"` 消息，靠 `tool_call_id` 对齐，强制调用用 `tool_choice: "required"`（2026-10-07 核实过 OpenAI 社区公告，2024 年 4 月上线）。两家的 SDK 我都写过，迁移时最容易把 Anthropic 的 `tool_result` 块原样搬过去，跑不通。

## 什么时候该上框架

工具超过十个、需要持久化会话状态、要接 MCP（Model Context Protocol）服务器的时候，框架省事。

反过来说，工具就三五个、流程一条道走到黑的场景，60 行代码更好调试。每一步 messages 里有什么，`print` 一下全在眼前。

我的建议是先手写一遍。不用框架不代表不用看框架，看过 LangChain 的源码再回来看这个循环，你会发现大多数"魔法"就是这个 for 循环套了层壳。
