> For the complete documentation index, see [llms.txt](https://levon.gitbook.io/agent-engineering/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://levon.gitbook.io/agent-engineering/03-gong-ju-diao-yong-xun-huan.md).

# 第 3 课：Tool Calling Loop——从调用请求到最终回答

如果你直接让大语言模型心算多位数乘法，它能面不改色、极其流畅地编造出一个完全错误的数字，并在末尾附赠一句礼貌的“计算完毕”。大模型擅长把语言编织得无懈可击，却连基础的四则符号计算都可能在幻觉中翻车。

解决之道简单粗暴：给它塞一把算盘。但在模型试图胡乱口算之前把算盘塞进它手里，并不是简单写两行 `if tool_call:`。

看一个具体的例子——你让 Agent 计算 `248 × 15`。屏幕没有立刻出现答案，而是先后打印：

```
step 1: finish_reason=tool_calls
step 1: multiply call_id=call_1 result={"status":"completed","result":3720}
step 2: finish_reason=stop
Agent> 248 乘以 15 等于 3720。
```

第一次，Model 申请使用乘法工具。Python 算出 `3720` 后，把结果送回去。第二次，Model 才写出最终回答。

这段“Model 申请工具，程序执行，结果送回，Model 再继续”的循环，就叫 Tool Calling Loop。本课只把这条最小循环跑通，所以使用没有文件和网络副作用的整数乘法。

## 1. 为什么一次乘法留下了四条 Message？

对话历史中的每一条记录都叫 Message。这个乘法任务最后留下四条：

```
1. User：帮我算一下 248 乘以 15
2. Assistant Tool Call：multiply(a=248, b=15)，id=call_1
3. Tool Result：result=3720，tool_call_id=call_1
4. Assistant Final：248 乘以 15 等于 3720
```

它们不是一次 API 返回的。运行过程是：

```
第一次请求：User + Tool Schema
第一次返回：Assistant Tool Call
本地执行：  multiply(248, 15)
第二次请求：前三条 Message + Tool Schema
第二次返回：Assistant Final
```

这里有四条 Message、两次模型请求、一次本地工具执行，但只处理了一个用户问题。从 User 提问到 Assistant Final 的整个过程，叫一个 User Turn。

最重要的责任边界也已经出现：Model 只生成 `multiply` 的调用申请，真正执行 `a * b` 的是本地 Python。

## 2. 为什么 Tool 只接收两个整数？

本地工具只接收两个整数：

```python
MAX_ABS_VALUE = 1_000_000


def multiply(a: int, b: int) -> int:
    if (
        isinstance(a, bool)
        or isinstance(b, bool)
        or not isinstance(a, int)
        or not isinstance(b, int)
    ):
        raise ValueError("a 和 b 必须是整数")
    if abs(a) > MAX_ABS_VALUE or abs(b) > MAX_ABS_VALUE:
        raise ValueError("数字太大")
    return a * b
```

为什么不用更省事的 `eval(expression)`？因为 `expression` 来自 Model，而 Model 又受用户和外部内容影响。`eval()` 执行的是 Python 代码，不是受限数学语言；不可信输入可能借它读取文件、导入模块或启动进程。[Python `eval()`](https://docs.python.org/3/library/functions.html#eval)

固定的 `multiply()` 功能少，但边界清楚：Model 只能提供数据，不能改变程序准备执行什么代码。

`bool` 需要单独拒绝，因为 Python 中 `isinstance(True, int)` 是 `True`。如果只检查 `int`，`multiply(True, 15)` 会悄悄得到 `15`。

Model 还需要知道这个工具怎样申请。程序会提供一张工具说明书，正式名称是 Tool Schema：

```python
TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "multiply",
            "description": "Multiply two integers",
            "parameters": {
                "type": "object",
                "properties": {
                    "a": {"type": "integer"},
                    "b": {"type": "integer"},
                },
                "required": ["a", "b"],
                "additionalProperties": False,
            },
        },
    }
]
```

它告诉 Model：工具名是 `multiply`，必须提供整数 `a` 和 `b`，不能增加其他字段。Schema 不会执行 Python，也不是权限证明。它只是帮助 Model 填对申请单。

这里的 Tool Schema、Tool Arguments 和 Tool Result 都使用 JSON。JSON 可以把一份数据写成清楚的“字段名和值”，例如 `{"a":248,"b":15}`。这一课传递的是一个个完整对象，还没有开始保存会话流水。

## 3. 代码怎样从第一步走到第二步？

Harness 中负责反复请求 Model、执行工具和回传结果的这段循环，叫 Agent Loop。每次请求后，它只做三种选择：执行工具并继续、返回 Final，或者报错停止。

```python
for step in range(MAX_STEPS):
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        tools=TOOLS,
    )
    choice = response.choices[0]
    message = choice.message
    messages.append(assistant_message_from_api(message))

    if message.tool_calls:
        if choice.finish_reason != "tool_calls":
            raise RuntimeError("Tool Call 与停止原因不一致")
        for tool_call in message.tool_calls:
            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": execute_tool(tool_call),
                }
            )
        continue

    if choice.finish_reason == "stop":
        return message.content or ""

    raise RuntimeError("模型没有正常结束")

raise RuntimeError("超过最大模型请求次数")
```

每次返回后，Harness 先保存完整 Assistant Message。若里面有 Tool Call，就逐个执行并追加 Tool Result。`continue` 表示回到循环开头，再请求一次 Model。

若没有 Tool Call，并且这次生成正常结束，代码才返回 Final。其他状态使用 `raise` 报错并停止。

`assistant_message_from_api()` 只是把 SDK 返回的 Message 转成可以放进 `messages` 的字典。完整转换代码留在配套文件中，这里先盯住循环的三个出口。

`step` 表示第几次模型请求，不是消息数。一次请求可能生成多个 Tool Call，因此也可能追加多条 Tool Result。

## 4. 两份结果怎样找到各自的申请单？

Assistant 可能一次请求两个工具：

```
call_1 → multiply(248, 15)
call_2 → multiply(6, 7)
```

每份 Tool Result 都要带回原 ID：

```python
{
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": result,
}
```

每次 Tool Call 都有一个订单号，字段名是 `id`。Tool Result 回传时，把同一个值放进 `tool_call_id`。没有这条对应关系，Model 无法判断哪份结果回答哪次调用。只有 Tool Result、没有前面的 Assistant Tool Call，也是一张找不到原订单的回执，提供模型 API 的服务方（Provider）可以拒绝整个请求。

同一批 Tool Call 可以顺序执行，也可以并行执行，但下一次请求必须包含每一个调用的结果。失败和拒绝也要带原来的 ID 返回，不能静默丢掉。

## 5. Model 写错工具参数，是返回错误还是停止运行？

先看一份写错但完整的申请：

```json
{
  "name": "multiply",
  "arguments": "{\"a\":2,\"b\":3,\"command\":\"whoami\"}"
}
```

`multiply` 只接受 `a` 和 `b`。多出来的 `command` 不能执行，但这份申请仍然完整，Agent 可以把错误原样告诉 Model：

```json
{
  "status": "error",
  "message": "multiply 只接受 a 和 b"
}
```

外层 Loop 会把这段 JSON 放进带有原 `tool_call_id` 的 Tool Result。Model 下一次看到错误后，可以删掉多余参数，重新申请。

真正调用本地函数前，需要有一段代码检查申请并找到对应工具。这段代码通常叫 Router，也就是工具路由器。本例按顺序检查：

```
工具名是不是 multiply？
→ arguments 能不能解析成 JSON？
→ 解析结果是不是对象？
→ 参数名是否刚好只有 a 和 b？
→ a 和 b 是否满足 multiply 自己的限制？
```

Tool Schema 会告诉 Provider 和 Model 期望的参数形状。有些 Provider 还支持严格约束，但这个 OpenAI-compatible 教学程序不能假定所有端点都同样严格。Harness 仍要在调用本地函数前检查工具名、参数和业务边界。

完整 Router 是：

```python
def execute_tool(tool_call: object) -> str:
    try:
        if tool_call.function.name != "multiply":
            raise ValueError("未知工具")
        arguments = json.loads(tool_call.function.arguments)
        if not isinstance(arguments, dict):
            raise ValueError("工具参数必须是 JSON 对象")
        if set(arguments) != {"a", "b"}:
            raise ValueError("multiply 只接受 a 和 b")
        payload = {
            "status": "completed",
            "result": multiply(arguments["a"], arguments["b"]),
        }
    except (json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
        payload = {"status": "error", "message": str(error)}
    return json.dumps(payload, ensure_ascii=False)
```

`try` 中任何一步失败，`except` 都会把它变成受控错误。`json.dumps()` 再把结果字典转成 Tool Result 所需的 JSON 字符串。

另一种情况绝不能当作普通参数错误处理：**当响应中包含 Tool Call，但 `finish_reason` 却显示输出被长度截断（length）时。**

`message.tool_calls` 是 Model 生成的工具申请，而 `choice.finish_reason` 是 Provider 给出的停止原因。前者要求“执行工具”，后者却表明“输出已半途截断”，此时参数极可能只生成了一半：

```json
{"a":248,"b":
```

这次不是某个字段填错，而是整份响应自相矛盾。Harness 必须丢弃这批 Tool Call 并停止当前 Loop，不能执行后再看结果。以后可以增加“重新请求 Model”的恢复策略，但仍不能执行这份可疑申请。

```
完整申请，参数错误 → 返回 error Tool Result → Model 可以重试
响应被截断或自相矛盾 → 不执行 Tool → 停止当前 Loop
```

最大请求次数是最后一道刹车。它能阻止 Agent 无限循环、持续花钱，却不能修复错误 Prompt 或反复失败的 Tool。

## 6. 自己运行，再故意弄坏它

先克隆仓库并运行本地自检：

```bash
git clone https://github.com/unix2dos/agent-engineering-book.git
cd agent-engineering-book
python -B examples/lesson_03_tool_calling_loop.py --self-check
```

预期输出：

```
self-check passed
```

自检不调用真实 Model，而是让一个按固定剧本返回的假 Model 配合测试。这就是 Fake Model Response。它验证一次成功调用、额外参数被拒绝、Tool Result ID 配对，以及长度截断不会被当作 Final。

自检通过，只说明本地消息和循环能工作。它没有检查 API Key、网络、真实 Model 或兼容 Provider 是否支持 Tool Calling。

接入真实 API 运行使用当前 [openai-python v3.7.0](https://github.com/openai/openai-python/releases/tag/v3.7.0)：

```bash
python -m pip install openai

export OPENAI_API_KEY="your-api-key"
export OPENAI_MODEL="your-model"
# 第三方兼容端点才需要设置：
export OPENAI_BASE_URL="https://provider.example/v1"

python examples/lesson_03_tool_calling_loop.py
```

当前 OpenAI 官方指南主要使用 Responses API 的 `function_call → function_call_output`。本章使用 Chat Completions 的 `assistant.tool_calls → role=tool`，是为了观察许多 OpenAI-compatible Provider 仍在使用的四条 Message。字段外形不同，责任链相同：Model 申请，应用执行，结果回传。[OpenAI Function Calling](https://developers.openai.com/api/docs/guides/function-calling)

在线请求第一次就返回“模型不支持 tools”时，本地 `multiply()` 尚未执行。应先检查 Provider、模型能力、模型名和 API 路径，而不是修改乘法函数。

最值得亲手实现的是 `run_agent_loop()` 的三个出口分支、Tool Result 的严格 ID 配对，以及请求次数硬上限这道刹车逻辑。样板代码和 Mock 结构可直接参考示例脚本；不同 API 规范的字段外形只需理解映射关系，核心是掌握“工具调用必须成对闭环”的控制流。

完整代码不要抄完就算结束。进入[阶段一～二综合实践](https://github.com/unix2dos/agent-engineering-book/tree/main/exercises/phase-1-capstone/README.md)，亲手完成第一关。它补充验证同批多个 Tool Call、Tool Call 与停止状态矛盾，以及达到模型请求上限。

## 7. 本课还没有解决什么

本课结束时，完整 Turn 只在 Python 内存里：

```
User → Assistant Tool Call → Tool Result → Assistant Final
```

程序退出后，消息全部消失；代码也没有 Context 预算、摘要、长期 Memory 或副作用恢复。下一课从这四条 Message 出发，区分 [Session 持久化、Transcript、Checkpoint 与 Memory](/agent-engineering/04-hui-hua-chi-jiu-hua.md)。

## 主动回忆

1. 完整 Tool Calling Turn 的四条 Message 按什么顺序出现？
2. 为什么不能把 Model 生成的表达式交给 `eval()`？
3. 有 Tool Schema，执行侧为什么仍要验证？
4. 为什么必须先保存 Tool Call，再保存对应 Tool Result？
5. `message.tool_calls` 与 `choice.finish_reason` 分别说明什么？
6. 最大步骤数限制什么，又不能解决什么？
7. 为什么 `multiply()` 要拒绝 `bool`？
8. `--self-check` 能证明什么，不能证明什么？

<details>

<summary>检查简答</summary>

1. `user -> assistant(tool_calls) -> tool(tool_call_id, result) -> assistant(final)`。
2. `eval()` 会执行不可信 Python 代码；固定函数只接收数据并执行固定动作。
3. Schema 描述期望形状，Provider 是否严格执行取决于能力和模式；Harness 仍要校验实际参数与业务边界。
4. Tool Result 必须用 ID 回答已经存在的 Tool Call，否则请求与回执无法配对。
5. 前者保存 Model 生成的调用，后者说明 Provider 为什么停止生成；两者矛盾时不能执行 Tool。
6. 它限制模型请求次数、时间和费用；不能修复循环根因。
7. Python 把 `bool` 当作 `int` 的子类。
8. 它验证本地消息组织和控制流；不验证凭据、网络、Provider 或真实 Model。

</details>

## 参考资料

> 资料最后核验于 2026-09-03；会变化的源码锚点收录在下面的复核记录中。

* [本批章节一手资料复核](https://github.com/unix2dos/agent-engineering-book/tree/main/research/01-05-chapter-promotion-sources.md)
* [完整教学代码](https://github.com/unix2dos/agent-engineering-book/blob/main/examples/lesson_03_tool_calling_loop.py)
* [阶段一～二综合实践](https://github.com/unix2dos/agent-engineering-book/tree/main/exercises/phase-1-capstone/README.md)
* [OpenAI Function Calling](https://developers.openai.com/api/docs/guides/function-calling)
* [openai-python v3.7.0](https://github.com/openai/openai-python/releases/tag/v3.7.0)
* [Python `eval()`](https://docs.python.org/3/library/functions.html#eval)


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://levon.gitbook.io/agent-engineering/03-gong-ju-diao-yong-xun-huan.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
