# 用 Claude API 从零构建 AI 智能体

> 基于 Claude API 的 AI 智能体，本质上是一个循环。你发送对话和工具，运行 Claude 要求的工具，追加结果，再次调用，直到 stop_reason 不再是 tool_use。回合上限能挡住失控的循环，压缩能让历史保持在上下文窗口之内。本课用 Python 把这个循环写出来。

Mindket 编辑部 · 发布于 2026-11-04 · 更新于 2026-11-05 · https://mindket.com/cn/learn/claude-api/build-ai-agent-claude-api

## 要点

- stop_reason 是 tool_use 就继续循环，并且每个回合都追加完整的响应。
- 自己限制回合数，因为客户端循环没有内置上限。
- 把工具失败作为 is_error 结果返回，并检查模型选的每一个路径。
- 用缓存和压缩应对上下文增长，再为循环选对工具。

## 开始之前

这一课用到前面学的所有内容：第 1 课的 Messages 调用、第 2 课的工具往返，以及第 3 课的缓存。如果智能体这个词对你还陌生，请先读[什么是 AI 智能体](https://mindket.com/cn/learn/prompt-engineering/what-is-an-ai-agent)。

[Agent SDK 总览](https://code.claude.com/docs/en/agent-sdk/overview)把智能体定义为：通过规划自己的步骤并调用工具来完成任务的应用。在 Claude API 上，它就是围绕一次调用的循环：Claude 选出下一步，你的代码运行它，结果再送回去。示例用两个工具 `list_files` 和 `read_file`，来回答关于一个 Markdown 文件夹的问题。

## 写出智能体循环

这个循环沿用[工具调用原理页面](https://platform.claude.com/docs/en/agents-and-tools/tool-use/how-tool-use-works)里的标准形态：`stop_reason` 是 `tool_use` 就继续调用。文档的[工具智能体教程](https://platform.claude.com/docs/en/agents-and-tools/tool-use/build-a-tool-using-agent)里那个基础循环没管的三件事，这里补上了：回合上限、路径检查和 `is_error` 结果。

代码是为本课写的示例。在 `docs` 文件夹里放几个 Markdown 文件，导出你的密钥，再运行它。

````python
from pathlib import Path

import anthropic

client = anthropic.Anthropic()

MODEL = "claude-opus-5-5"
MAX_TURNS = 12
ROOT = Path("docs").resolve()
SYSTEM = "Answer only from the files in the docs folder. If the answer is not there, say so."

TOOLS = [
    {
        "name": "list_files",
        "description": (
            "List the Markdown files in the docs folder. Use it first to see what "
            "exists. Pass a glob such as 'guides/*.md' to narrow the list. It returns "
            "paths relative to the docs folder, one per line."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "pattern": {"type": "string", "description": "Optional glob, default **/*.md"},
            },
        },
    },
    {
        "name": "read_file",
        "description": (
            "Read one Markdown file from the docs folder and return its text. Use a "
            "path from list_files. It fails for any path outside the folder."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path relative to the docs folder"},
            },
            "required": ["path"],
        },
    },
]


def safe_path(relative: str) -> Path:
    path = (ROOT / relative).resolve()
    if not path.is_relative_to(ROOT):
        raise ValueError("That path is outside the docs folder.")
    return path


def run_tool(name: str, args: dict) -> str:
    if name == "list_files":
        pattern = args.get("pattern", "**/*.md")
        files = sorted(p.relative_to(ROOT).as_posix() for p in ROOT.glob(pattern) if p.is_file())
        return "\n".join(files) or "No files match."
    if name == "read_file":
        return safe_path(args["path"]).read_text(encoding="utf-8")[:20000]
    raise ValueError(f"Unknown tool: {name}")


def run_agent(task: str) -> str:
    messages = [{"role": "user", "content": task}]
    for turn in range(1, MAX_TURNS + 1):
        response = client.messages.create(
            model=MODEL,
            max_tokens=2048,
            system=SYSTEM,
            tools=TOOLS,
            cache_control={"type": "ephemeral"},
            messages=messages,
        )
        print(f"turn {turn}: {response.stop_reason}, cache read {response.usage.cache_read_input_tokens}")
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason != "tool_use":
            break
        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            try:
                content, is_error = run_tool(block.name, block.input), False
            except Exception as error:
                content, is_error = str(error), True
            results.append(
                {"type": "tool_result", "tool_use_id": block.id, "content": content, "is_error": is_error}
            )
        messages.append({"role": "user", "content": results})
    else:
        return f"Stopped after {MAX_TURNS} turns."

    if response.stop_reason != "end_turn":
        return f"Stopped early: {response.stop_reason}"
    return "".join(block.text for block in response.content if block.type == "text")


print(run_agent("Which file explains how to reset a password, and what is its first heading?"))
````

1. 用较长的描述定义这两个工具，做法见第 2 课。
2. 检查每一个路径。`safe_path` 会解析路径，拒绝 `docs` 之外的一切，因为参数是模型选的，你的代码是唯一的把关人。
3. 只维护一个 `messages` 列表，每个回合都往里追加：先是完整的助手 `response.content`，再是一条包含所有 `tool_result` 的用户消息。
4. 只要 stop_reason 不是 `tool_use` 就离开循环，并且仅在 `end_turn` 时返回文字。
5. 数回合数。如果 `for` 循环用完了，`else` 分支会报告上限，而不是继续转下去。
6. 运行它，留意缓存读取的数字。它从 0 开始，历史超过模型的最小长度（Opus 5.5 上是 512 个 token）后开始上升。

## 每个回合发生了什么

下面这段过程勾勒的是示例中那个问题的消息流向。它用来演示协议，不是一次真实运行的记录。

**一个问题，三次调用**

1. Claude 要文件列表。第一个响应以 stop_reason 为 tool_use 结束，里面是一次 list_files 调用。你的代码运行它，输出作为 tool_result 放进用户消息里送回去。
2. Claude 打开一个文件。第二个响应用那个列表里的一个路径调用 read_file。它的结果送回去之后，历史共有五条消息：任务、两个助手回合和两个结果回合。
3. Claude 给出回答。第三个响应的 stop_reason 是 end_turn，里面是一个文本块。循环退出，并返回这段文字。
4. 每次调用都重发历史。第三次请求比第一次大。有了顶层的 cache_control，前缀达到模型的最小长度之后，已经见过的部分就从缓存里读取。

## 停止条件

Claude 什么时候说完，由 API 决定。你什么时候放弃，由你决定。文档里的基础循环就是对 `stop_reason` 的一个 `while`，工具运行器的 `max_iterations` 也是可选的，所以没有自己计数器的循环，可能一直跑到上下文被占满。

数回合并设上限。如果你有预算，还要把每个响应的 `usage` 累加起来。[停止原因页面](https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons)里没有哪个停止原因会报告这两种限制，所以数回合和算 token 预算都得写在你的代码里。

| 停止信号 | 来自哪里 | 循环该怎么做 |
| --- | --- | --- |
| `end_turn` | Claude 说完了 | 返回文字 |
| `tool_use` | Claude 想用工具 | 运行它，追加结果，再次调用 |
| `max_tokens` | 回复达到了你的上限 | 停下并报告，或者调高上限 |
| `refusal` | Claude 拒绝了 | 读 `stop_details`，再换备用模型重试 |
| `model_context_window_exceeded` | 上下文窗口占满了 | 把回复当作被截断的，更早地压缩 |
| `pause_turn` | 服务端工具循环达到了上限 | 把响应原样送回去，再继续 |
| 回合数、token 预算、时钟 | 你自己的代码 | 停下并说明原因 |

## 上下文增长

每个回合都要重发整段历史，所以每次调用都比上一次大。增长主要来自工具结果：read_file 返回的文件，会留在之后的每一次请求里。示例把文件截断在 20,000 个字符，原因就在这里。提示词缓存让重复的部分更便宜，但不会让它变小。

大小的影响不止于窗口。[压缩总览](https://platform.claude.com/docs/en/build-with-claude/compaction)说，对话越长，回复质量越会下降，所以它提供了把较早的回合换成 Claude 在服务端写的摘要的做法。两种压缩目前都在 beta 阶段。

对这样的循环来说，阈值压缩改动更小：在普通请求上加一个参数，把带压缩块的响应整个放进历史就行。按需压缩适合必须自己挑时机的应用，文档说，凡是能用按需压缩的地方都该用它。

**影响循环的几个数字**

- **1M** 每个当前模型的上下文 token 数 (来源：[Anthropic Docs: Models overview](https://platform.claude.com/docs/en/models/overview), 2026-11-05 查阅)
- **150,000** 阈值压缩默认开始之前的输入 token 数 (来源：[Anthropic Docs: Compaction at a token threshold](https://platform.claude.com/docs/en/build-with-claude/compaction-threshold), 2026-11-04 查阅)
- **50,000** 你能设置的最低触发值 (来源：[Anthropic Docs: Compaction at a token threshold](https://platform.claude.com/docs/en/build-with-claude/compaction-threshold), 2026-11-04 查阅)

````python
response = client.beta.messages.create(
    betas=["compact-2026-01-12"],
    model="claude-opus-5-5",
    max_tokens=4096,
    messages=messages,
    context_management={"edits": [{"type": "compact_20260112"}]},
)
messages.append({"role": "assistant", "content": response.content})  # the compaction block stays in
````

| 选项 | 谁决定时机 | 会发生什么 |
| --- | --- | --- |
| 按 token 阈值压缩 | API，按你设的触发值 | 在普通请求里做摘要。beta 请求头 `compact-2026-01-12`，编辑类型 `compact_20260112` |
| 按需压缩 | 你的应用 | 单独发一个把 `compaction` 设为 summarize 的请求，返回一个摘要块，放在 `messages` 的最前面。beta 请求头 `compact-2026-09-04` |
| 上下文编辑 | 你设定的规则 | 清除旧的工具结果或旧的 thinking 块，而不是做摘要 |

## 什么时候不再手写循环

循环先亲手写一遍，像上面那样，这样你才知道库替你做了什么。工具运行器在各 SDK 里还是 beta，它用 `client.beta.messages.tool_runner(...)` 来运行同样的循环。它把工具异常变成 `is_error` 结果，并在你设置了 `max_iterations` 时到点停下。需要人工审批、自定义日志或有条件执行时，文档仍然让你回到手写循环（见[工具运行器页面](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-runner)）。

Agent SDK 是另一类工具。它是作为库的 Claude Code，带着自己内置的文件、命令行和网页工具、钩子、子智能体、MCP、权限和会话，支持 Python 和 TypeScript（见[总览](https://code.claude.com/docs/en/agent-sdk/overview)）。想要整套这样的框架时就用它。[Claude Agent SDK 那一课](https://mindket.com/cn/learn/claude-code-advanced/claude-agent-sdk)讲了怎么用。

**在 Claude 上运行智能体的三种方式**

| | 手写循环 | 工具运行器 (我们的选择) | Agent SDK |
| --- | --- | --- | --- |
| 替你运行循环 | ✗ 否 | ✓ 是 | ✓ 是 |
| 工具运行前需要审批 | ✓ 是 | ✗ 否 | ✓ 是 |
| 内置的文件、命令行和网页工具 | ✗ 否 | ✗ 否 | ✓ 是 |
| 钩子、子智能体和会话 | ✗ 否 | ✗ 否 | ✓ 是 |
| 可以直接走普通 HTTP | ✓ 是 | ✗ 否 | ✗ 否 |

**我们的选择：工具运行器。** 如果用的是你自己的工具，又不需要审批步骤，它能省掉循环代码，并保留你自己的请求。每次调用都要有人审批时选手写循环，想用 Claude Code 自带的工具时选 Agent SDK。

来源：[Anthropic Docs: Tool runner](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-runner) (2026-11-04 查阅)；[Claude Code Docs: Agent SDK overview](https://code.claude.com/docs/en/agent-sdk/overview) (2026-11-04 查阅)

## Simon Willison 和 Anthropic 谈智能体循环

Simon Willison 是 Datasette 的作者、Django 的共同创建者。他在 2025-09-18 [在 X 上说](https://x.com/simonw/status/1968755421201711588)，自己终于愿意接受一个智能体定义了，而多年来他一直觉得这个词含糊得没法用。这个定义就是下面引用的那句话，本课的循环正好符合：工具是 `list_files` 和 `read_file`，目标就是用户的问题。

Anthropic 在 2025-09-30 [发帖](https://x.com/AnthropicAI/status/1973098580060631341)推荐工程博客上讲[上下文工程](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents)的文章。文章点出了上下文增长背后的问题：窗口越满，模型越难准确回忆其中的内容，作者把这叫作 context rot（上下文腐化）。在最安全、最轻量的压缩方式里，它列出了清除旧的工具结果，也就是压缩表里“上下文编辑”那一行做的事。先试这个，再考虑摘要。

> LLM 智能体，就是为了达成目标而在循环里运行工具。
>
> Simon Willison, Datasette 作者、Django 共同创建者，引文为译文, [X](https://x.com/simonw/status/1968755421201711588) (2026-11-05 查阅)

[![Simon Willison（@simonw）2025 年 9 月 18 日在 X 上发帖：我愿意接受一个我认为已被广泛认同、足以派上用场的“智能体”定义。LLM 智能体就是为了达成目标而在循环里运行工具。他还说，多年来他一直认为这个词含糊得无可救药。](https://mindket.com/blog/shots/x-simonw-tools-in-a-loop.webp?v=fb0c262da094)](https://x.com/simonw/status/1968755421201711588)

Simon Willison 在 X 上，2025 年 9 月 18 日

[![Anthropic（@AnthropicAI）2025 年 9 月 30 日在 X 上发帖：Anthropic 工程博客新文章。大多数开发者都听说过提示词工程，但要让 AI 智能体发挥最大作用，你需要上下文工程。我们解释它是怎么回事。](https://mindket.com/blog/shots/x-anthropicai-context-engineering.webp?v=89912d75f228)](https://x.com/AnthropicAI/status/1973098580060631341)

Anthropic 在 X 上，2025 年 9 月 30 日

[How We Build Effective Agents: Barry Zhang, Anthropic](https://www.youtube.com/watch?v=D7_ipDqhtwk)

AI Engineer 的 YouTube 频道上，Anthropic 的 Barry Zhang 做的演讲，标题是 How We Build Effective Agents。

> X 官方嵌入卡片的截图，拍摄于 2026-11-05，时间为 UTC。点击截图可到 X 上打开原帖。Anthropic 那张卡片省略了原帖结尾的链接。

## 常见错误

- 循环没有回合上限，结果在账单上才发现。
- 用文本块重建助手消息，丢掉了 API 要求传回的 thinking 块。
- 把每个 tool_result 放进单独的用户消息，而不是每个回合一条消息。
- 对模型选的路径、命令或网址，不在代码里检查就信任。
- 把整个文件或整个页面作为一个结果返回，让它跟着出现在之后的每一次请求里。
- 把工具结果里的文字当作指令。它是不可信的数据。
- 把 stop_reason 为 max_tokens 当作已经完成的回答。

## 自己试一试

- 把 `MAX_TURNS` 设为 2，再问一个需要三次工具调用的问题。函数返回了什么？
- 让智能体去读 `../secrets.txt`，看那个错误结果。Claude 是恢复了，还是做了解释？
- 连续把同一个问题问两遍，比较每个回合的缓存读取数字。
- 用工具运行器重写 `run_agent`，比较剩下多少代码。

## 接下来

这个智能体靠列出和打开文件来找答案，文件夹里有几千个页面时，这个办法就行不通了。下一门课用 OpenAI 的 API 构建智能体，从[OpenAI API 快速入门](https://mindket.com/cn/learn/openai-api/openai-api-quickstart)开始。之后的[RAG 教程](https://mindket.com/cn/learn/rag/rag-tutorial)讲怎样对你自己的文档做检索。如果你希望这些工具也能被其他客户端调用，[MCP 服务器那一课](https://mindket.com/cn/learn/mcp/build-mcp-server-python)讲了怎样把它们包成一个 MCP 服务器。

## 自测一下

1. Claude 一直在调用工具时，什么能避免智能体循环永远跑下去？

   A. API 会在十个回合之后停下它
   B. 你自己代码里的回合上限
   C. tool_choice 参数
   D. 缓存的存活时间

   答案：B。 客户端循环只会在 stop_reason 不是 tool_use 时退出，所以回合数要你自己数。

2. 同一个助手回合里的所有 tool_result 块，该放在哪里？

   A. 每个放进单独的用户消息
   B. 放进系统提示词
   C. 放进一条用户消息，每个都带着自己的 tool_use id
   D. 放进一条助手消息

   答案：C。 每个回合一条用户消息，能让 id 对得上，也能让 Claude 继续做并行调用。

3. 为什么要追加完整的 response.content，而不是只追加文本块？

   A. tool_use 和 thinking 块必须原样传回
   B. 工具调用之后文本块总是空的
   C. 这样能让历史变短
   D. Python 列表没法过滤

   答案：A。 API 需要 tool_use 块来和结果配对，并且要求 thinking 块原样传回。

4. 在基于 Client SDK 的智能体里，每次工具调用都要有人审批，文档推荐什么？

   A. 工具运行器
   B. 按需压缩
   C. 提示词缓存
   D. 手写循环

   答案：D。 工具运行器页面让你在需要人工审批、自定义日志或有条件执行时，回到手写循环。

## 常见问题

### 循环该允许多少个回合？

文档没有给出数字。它的工具运行器示例里 max_iterations 用的是 10，本课的示例用的是 12。把你的上限设得比日志里最长的正常运行略高一点。

### 人工审批该加在哪里？

加在手写循环里，读完 tool_use 块和运行它们之间。如果对方说不行，就发一个 is_error 为 true、写明原因的 tool_result，因为每个 tool_use id 仍然需要一个回应。

### 同一个循环能用 Anthropic 的服务端工具，比如网页搜索吗？

可以，但要处理 pause_turn。服务端工具循环在达到迭代上限后会停下，文档给出的默认上限是 10，你把响应原样送回去，就能继续。

### 能让用户用 claude.ai 账号登录吗？

不能。除非 Anthropic 事先批准，第三方产品，包括基于 Agent SDK 构建的智能体，都不能提供 claude.ai 登录或它的速率限制。请使用 API 密钥认证。

## 资料来源

- [Anthropic Docs: How tool use works](https://platform.claude.com/docs/en/agents-and-tools/tool-use/how-tool-use-works) (2026-11-04 查阅)
- [Anthropic Docs: Tutorial, build a tool-using agent](https://platform.claude.com/docs/en/agents-and-tools/tool-use/build-a-tool-using-agent) (2026-11-04 查阅)
- [Anthropic Docs: Tool runner](https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-runner) (2026-11-04 查阅)
- [Anthropic Docs: Stop reasons and fallback](https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons) (2026-11-04 查阅)
- [Anthropic Docs: Prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) (2026-11-04 查阅)
- [Anthropic Docs: Compaction overview](https://platform.claude.com/docs/en/build-with-claude/compaction) (2026-11-04 查阅)
- [Anthropic Docs: Compaction at a token threshold](https://platform.claude.com/docs/en/build-with-claude/compaction-threshold) (2026-11-04 查阅)
- [Anthropic Docs: Compaction on demand](https://platform.claude.com/docs/en/build-with-claude/compaction-on-demand) (2026-11-04 查阅)
- [Anthropic Engineering: Effective context engineering for AI agents](https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents) (2026-11-05 查阅)
- [X: Simon Willison on the definition of an LLM agent](https://x.com/simonw/status/1968755421201711588) (2026-11-05 查阅)
- [X: Anthropic shares its context engineering article](https://x.com/AnthropicAI/status/1973098580060631341) (2026-11-05 查阅)
- [YouTube (AI Engineer): How We Build Effective Agents, Barry Zhang, Anthropic](https://www.youtube.com/watch?v=D7_ipDqhtwk) (2026-11-05 查阅)
- [Anthropic Docs: Models overview](https://platform.claude.com/docs/en/models/overview) (2026-11-05 查阅)
- [Claude Code Docs: Agent SDK overview](https://code.claude.com/docs/en/agent-sdk/overview) (2026-11-04 查阅)

[浏览 AI 智能体](https://mindket.com/cn/agents)
