> 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——从调用请求到最终回答

前两课明确了排查助手的目标和运行职责。这一课把“读日志、读源码、给出诊断”接成可以观察的循环：一次工具执行完，程序怎样把结果交回去，让下一步接着发生？

## 1. 执行轨迹：两次工具调用与三次模型请求

下面先看一次本地样例的输出。HTTP 请求、错误日志和文件读取真实发生；模型使用预设规则替身，不能据此证明真实模型已经具备排障能力。关键结果和代码都列在本章，不运行也可以跟着读。

```
HTTP: GET /orders/order_404 -> 500
Tool: read_file server.log
Tool: read_file order_service.py
定位：order_service.py:10，读取 order["total"] 时出错。
```

想复现这次结果，可以在仓库目录运行以下命令，不需要配置 Key。`-B` 表示不生成 Python 字节码缓存。

```bash
python -B practice/lesson-03/demo.py
```

服务找不到这个订单，代码却继续读取它的金额，因而报错。这里只诊断，没有修复。服务返回 500，也不代表读取工具失败：工具已经成功读到了错误日志。

从用户提出问题到最终回答，这一轮交互叫一个 **Turn**。本例留下六条消息：

```
1  User：排查这个请求
2  Assistant Tool Call：读日志，编号 read_1
3  Tool Result：read_1 对应的日志内容
4  Assistant Tool Call：读源码，编号 read_2
5  Tool Result：read_2 对应的源码内容
6  Assistant Final：给出诊断
```

它来自三次模型请求：第一次的消息列表只有用户问题；第二次带前 3 条消息；第三次带前 5 条消息。工具说明则随请求另行提供。工具执行两次，模型响应三次，最终积累六条消息。它们不是同一个计数。

## 2. Tool Schema 与执行校验

模型需要知道工具名、用途以及参数怎样填写。这份工具说明叫 **Tool Schema**。本例只允许读取两份材料，核心 Schema 是：

```json
{
  "name": "read_file",
  "description": "读取本次请求的日志或订单源码",
  "parameters": {
    "type": "object",
    "properties": {
      "path": {
        "type": "string",
        "enum": ["server.log", "order_service.py"]
      }
    },
    "required": ["path"],
    "additionalProperties": false
  }
}
```

`enum` 列出允许值，`required` 指定必填字段，`additionalProperties: false` 表示不接受额外字段。这里只展示工具说明，外层包装按模型 API 适配。

Schema 用来指导模型填写，执行入口仍要检查真正收到的内容。下面沿用样例的只读入口：

```python
import json

ALLOWED_FILES = {"server.log", "order_service.py"}

def execute_read(workspace, call):
    try:
        if call.function.name != "read_file":
            raise ValueError("本例只开放 read_file")
        arguments = json.loads(call.function.arguments)
        if not isinstance(arguments, dict) or set(arguments) != {"path"}:
            raise ValueError("参数必须只包含 path")
        path = arguments["path"]
        if not isinstance(path, str) or path not in ALLOWED_FILES:
            raise ValueError("只能读取 server.log 和 order_service.py")
        result = read_file(workspace, path)
    except (OSError, ValueError, TypeError) as error:
        result = {"error": str(error)}
    return json.dumps(result, ensure_ascii=False)
```

`call.function.arguments` 是模型接口返回的 JSON 字符串，`json.loads()` 把它解析成字典。检查通过后，才调用已有的 `read_file`；最后用 `json.dumps()` 把结果转成可回传的文字。

`isinstance(arguments, dict)` 检查参数是否为字典；`set(arguments)` 取出它的字段名，用来确认只有 `path`，既没缺字段，也没多塞字段。

这里复用的 `read_file` 限制解析后的路径必须位于工作区内，并限制读取字节数，返回包含 `path`、`content` 等字段的字典。它不是 Python 自带函数。上述代码展开的是调用入口，基础读取实现仍由样例提供；不要把片段当成完整独立脚本。

这样的入口承担 **Router（工具路由器）** 的职责。本例只有一个工具，直接按名称检查即可，不需要为了路由再搭一套框架，也不要用 `eval()` 执行模型给出的字符串。\[1]

## 3. Tool Result：按调用编号回传

工具读到了内容，还不能直接把它当最终回答。它先要回答对应的调用：

```
read_1 申请读 server.log       -> 结果带 tool_call_id=read_1
read_2 申请读 order_service.py -> 结果带 tool_call_id=read_2
```

这个编号区分的是调用，不是工具名称。两次都调用 `read_file`，仍然需要各自的编号。否则模型无法可靠地知道一份结果在回答哪张申请。

消息列表的顺序也要保留：先追加完整的 Assistant Tool Call，再追加对应的 Tool Result。如果模型同一批提出多个调用，本批所有结果都处理好以后，才发起下一次模型请求。失败或拒绝也要带回原编号，不能悄悄丢掉。

本书这里沿用 OpenAI-compatible Chat Completions 的 `tool_calls`、`tool_call_id` 消息形状；其他 API 的字段可能不同，接入时需要适配。\[2] 编号关联和实际结果回传的职责不变。

## 4. Agent Loop：执行、回传、继续与停止

下面保留完整的主循环，省略 HTTP 服务启动、SDK 对象构造和会话落盘。`client` 负责请求模型，`tools` 是包装好的工具说明；`assistant_message_from_api` 把 SDK 返回对象转换成字典，保留回答和完整工具调用列表。这些组件都由现有样例提供。

```python
def run_loop(client, model, tools, workspace, question, max_requests=4):
    messages = [{"role": "user", "content": question}]

    for step in range(max_requests):
        response = client.chat.completions.create(
            model=model, messages=messages, tools=tools,
        )
        choice = response.choices[0]
        message = choice.message
        calls = message.tool_calls

        if calls and choice.finish_reason != "tool_calls":
            raise RuntimeError("Tool Call 与停止原因不一致")

        # 保留模型回答和完整调用列表，再执行工具。
        messages.append(assistant_message_from_api(message))
        if calls:
            for call in calls:
                result = execute_read(workspace, call)
                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": result,
                })
            continue

        if choice.finish_reason == "stop":
            return message.content or ""
        raise RuntimeError("模型没有正常结束")

    raise RuntimeError("超过模型请求次数上限")
```

`messages.append()` 把一条消息加到列表末尾。`continue` 回到循环开头，下一次请求因此带上新加入的工具结果；`return` 则结束整个函数，把最终文字交给调用方。看到工具调用时不能提前 `return`，那会让结果停在程序里，交不到模型手上。

`choices[0]` 取第一个候选回答，不是只取第一个工具调用。这个回答中的工具调用由 `for call in calls` 逐条处理。

`for step in range(max_requests)` 最多循环指定次数。这里限制的是模型请求次数，不是工具次数；一次响应可能包含多个调用。SDK 内部重试、网络超时和费用还需要另外配置与核对，这个循环不会自动替应用管完所有预算。

这段主循环与已有运行器使用同一套只读工具和消息转换，并通过配套检查核对请求中的消息是否一致。模型替身负责可重复的测试输入，真实模型接入后仍需验证其工具选择和诊断能力。

## 5. 错误反馈与停止语义

一份完整调用的参数可能不合要求，例如：

```json
{"path":"server.log","command":"delete everything"}
```

入口会返回 `{"error":"参数必须只包含 path"}`，不会执行多出来的命令。主循环把错误按调用编号交回，模型下一轮有机会修正参数。

响应被截断则是另一回事。模型接口返回 `finish_reason="length"`，即使消息里还有看起来完整的 Tool Call，也不能直接执行。这时先停止当前循环，不能拿半张申请试运行。

| 收到的情况                       | 本例如何处理             |
| --------------------------- | ------------------ |
| 有效完整调用，但工具名或参数不合规则          | 返回带原编号的错误结果，下一轮可修正 |
| 包含 Tool Call，却标记截断或不一致的停止原因 | 不执行工具，停止并报错        |
| 没有 Tool Call，且停止原因是 `stop`  | 返回最终回答             |
| 其他结束状态或用尽请求次数               | 停止并报告，不能冒充完成       |

上限用来限制循环，不负责提高答案质量。程序停止了，只说明这次不再继续调用，不等于订单故障已经解决。

最后只改一个输入，再观察一次：

```bash
python -B practice/lesson-03/demo.py --order-id order_001
```

已有订单返回 HTTP 200。模型替身读完日志后直接结束，不再申请源码，于是这一轮只有两次模型请求、一次工具执行。真实故障代码仍在，换回 `order_404` 还会出现 500。

本课把一轮交互接起来了。样例为了让过程可查，会把消息保存到文件；这些记录怎样归属到同一会话、重启后从哪里继续，下一课再用 Session、Transcript 与 Checkpoint 解释。

## 本课实践

[进入第 3 课实践](broken://pages/rhRdjOKp8IJh3dtTEXOb)：运行订单排查，换一个订单，解释工具申请、执行、回传和结束。说明中列出必做步骤、完成标准和选做内容。

## 资料

1. [Python：eval 的行为](https://docs.python.org/3/library/functions.html#eval)
2. [OpenAI：Function Calling](https://developers.openai.com/api/docs/guides/function-calling)
3. [协议与固定版本核验记录](https://github.com/unix2dos/agent-engineering-book/tree/main/research/01-05-chapter-promotion-sources.md)


---

# 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 by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

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

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

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.
