> ## Documentation Index
> Fetch the complete documentation index at: https://www.yuan111.asia/doc/llms.txt
> Use this file to discover all available pages before exploring further.

# 11 · 工具调用：让模型从"会说话"到"会动手"

# 11 · 工具调用：让模型从"会说话"到"会动手"

> **本片目标**：让模型在回答中"调用你提供的工具"——用 `@tool` 定义工具、`bind_tools` 让模型知道有工具可用、手动执行循环把结果塞回。全程不用 agent、不用 LangGraph、不用弃用包。
> **新增规定性：11**（工具调用协议：`tool_calls` / `ToolMessage` + 手动执行循环）
> **数据字典**：StructuredTool / ToolCall / ToolMessage / bind\_tools（内部是 RunnableBinding）。
> **进程线程模型**：一次工具问答 = 多次模型调用串行（声明 → 执行 → 再问），工具本身跑在主线程。
> **网络模型**：模型调用是网络请求；工具若是本地函数则 0 网络，若是查外部 API 则多一次 HTTP。

***

## 1. 上集回顾

第 10 篇 RAG 能回答文档问题了。但有个天花板：

> **RAG 是"只读"的**——模型从资料里找答案、念给你听。但"转正答辩还有几天？"这种问题，手册里**没有**现成答案，得**算**出来。

你当然可以写死：`答案 = 答辩日 - 今天`。但用户问法千变万化（"我 10 月 1 号答辩，来得及准备吗？"），写死永远跟不上。

**真正的解法**：让模型在回答时**调用你写的函数**——这就是工具调用（Tool Calling）。模型负责"理解问题 → 决定调哪个工具 → 填好参数"，你负责"执行函数 → 把结果喂回去 → 模型组织成答案"。

**关键认知**：**工具 ≠ agent**。agent 是"模型自主循环调用工具直到完成任务"的框架（LangGraph 那套）。工具调用是更底层的协议——**bind\_tools + 手动循环**就能实现，完全不用 agent。

***

## 2. 数据字典：四个主角

### 2.1 `@tool` 装饰器（把函数变成工具）

```python theme={null}
from langchain_core.tools import tool
from datetime import date

@tool
def days_until(target_date: str) -> int:
    """计算今天到目标日期还有几天（YYYY-MM-DD 格式）。"""
    target = date.fromisoformat(target_date)
    return (target - date.today()).days
```

`@tool` 自动从**函数签名 + docstring** 生成三样东西（实测）：

| 属性                       | 值                                     | 来源        |
| ------------------------ | ------------------------------------- | --------- |
| `days_until.name`        | `"days_until"`                        | 函数名       |
| `days_until.description` | `"计算今天到目标日期还有几天（YYYY-MM-DD 格式）。"`     | docstring |
| `days_until.args`        | `{'target_date': {'type': 'string'}}` | 函数参数类型    |

**为什么 docstring 必须写清楚**：模型靠 description 决定"什么时候该用这个工具"。写"计算两个日期差几天"比写"算天数"好——模型理解得越准，调用越对。

### 2.2 `bind_tools`（让模型知道有工具可用）

```python theme={null}
model_with_tools = model.bind_tools([days_until])
```

**内部结构（实测）**：`bind_tools` 返回的是 `_ChatModelBinding`——它是 `RunnableBinding` 的子类，本质是\*\*"原模型 + 额外参数"的绑定层\*\*：

```
_ChatModelBinding (实测 MRO)
 └─ RunnableBinding → RunnableBindingBase → RunnableSerializable
      ├─ .bound = 原模型（ChatAnthropic）     ← 真正的模型还在里面
      └─ .kwargs = {"tools": [工具 schema]}    ← 绑定的工具存在这
```

**它干了三件事**：

1. 把工具列表 `[days_until]` 转成模型厂商要的 **tools schema**（Anthropic/OpenAI 协议格式）；
2. 存进 `kwargs`，每次请求自动带上；
3. 返回一个"绑定后的模型"——**`invoke()` 的返回类型不变，还是 `AIMessage`**，只是这个 AIMessage 可能带 `tool_calls` 而不是普通文本。

`bind_tools` 返回一个绑定了工具 schema 的模型绑定。之后调用它，模型在需要时**返回 `tool_calls` 而不是普通回答**：

```python theme={null}
resp = model_with_tools.invoke("今天 2026-08-05，距离 2026-10-01 转正答辩还有几天？")
resp.tool_calls
# → [{'name': 'days_until', 'args': {'target_date': '2026-10-01'}, 'id': 'tool-...'}]
```

### 2.3 `ToolCall`（模型声明的数据结构）

| 字段     | 类型     | 说明                            |
| ------ | ------ | ----------------------------- |
| `name` | `str`  | 要调用的工具名（必须是你 bind 过的）         |
| `args` | `dict` | 参数（模型根据你的 schema 填的）          |
| `id`   | `str`  | 调用唯一标识（后面 `ToolMessage` 靠它配对） |

**注意**：模型只"声明"要调工具，**不执行**。执行是你的责任——这就是"手动"的含义。

### 2.4 `ToolMessage`（工具执行结果回传）

```python theme={null}
from langchain_core.messages import ToolMessage

messages.append(ToolMessage(
    content=str(result),      # 执行结果（必须是字符串）
    tool_call_id=tc["id"],    # ← 必须匹配 tool_calls 里的 id！
    name=tc["name"],
))
```

`tool_call_id` 是配对的钥匙：模型靠它知道"这个结果是对应我刚才哪次调用"。

***

## 3. 手动执行循环（核心代码）

```python theme={null}
def run_with_tools(question: str):
    messages = [("human", question)]
    model_calls = 0
    for turn in range(3):                  # 最多 3 轮，防死循环
        resp = model_with_tools.invoke(messages)
        model_calls += 1
        messages.append(resp)

        if not resp.tool_calls:            # 模型不再要工具 → 回答完成
            return resp.content, model_calls

        for tc in resp.tool_calls:         # 手动执行每个工具
            result = days_until.invoke(tc["args"])
            messages.append(ToolMessage(
                content=str(result),
                tool_call_id=tc["id"],
                name=tc["name"],
            ))
    return "（达到最大轮数，手动中断）", model_calls

answer, n = run_with_tools("今天 2026-08-05，还有几天到 2026-10-01 的转正答辩？")
# → "还有 57 天。"（模型算的，不是编的）共 2 次模型调用
```

**循环三步**（和记忆的"取历史→拼消息→写回"一样是固定骨架）：

```
用户问题 → 模型(带工具) → 返回 tool_calls？
    ├─ 否 → 这就是最终回答 ✅
    └─ 是 → 手动执行工具 → ToolMessage 塞回 → 再问模型（回到起点）
```

**为什么不是 agent**：循环是你写的 `for`，不是框架自动的。你控制轮数上限（防死循环）、决定执行哪些工具（白名单）、处理失败（try/except）。**主动权在你**——这正是本系列"不用 agent 也能做生产级"的哲学。

***

## 3.5 工具消息必须留在历史里（关键认知）

第 2 次调用前，`messages` 里实际装着什么（实测，2026-08）：

```
[human]  "今天 2026-08-05，还有几天到 2026-10-01 的转正答辩？"   ← 你最初的问题
[ai]     AIMessage(tool_calls=[{name: days_until, args: {...}, id: tool-6b0...}])  ← 模型第 1 次的"我要调工具"声明
[tool]   ToolMessage(content="56", tool_call_id=tool-6b0..., name=days_until)  ← 工具执行结果
```

**为什么这三条缺一不可**：模型第 2 次请求时，它看到的完整对话就是上面这三连。它靠：

* **`AIMessage.tool_calls` 里的 `id`** 记住"我上次说要调 `days_until` 这个工具"；
* **`ToolMessage.tool_call_id`** 认出"这个 `56` 就是那次调用的结果"；
* 两者配对后，它才能组织出最终回答："还有 **56 天**。"

如果**不把工具消息加进历史**（比如只把结果 `56` 直接当新消息发），模型会"失忆"——它不知道这个 `56` 是干嘛的，甚至可能当成用户说的话。**工具调用循环 = 记忆在"一轮对话内部"的微缩版**（呼应第 06 篇）：模型声明 → 执行结果 → 最终回答，都是同一条消息链上的环节，必须全部保留。

> **配对失败会怎样**：如果 `ToolMessage.tool_call_id` 对不上任何 `tool_calls` 里的 `id`，模型会报错或把工具结果当无效输入。所以 `tool_call_id` 一定要从 `tc["id"]` 原样抄过来，不能自己编。

***

## 4. 关键方法

| 方法                                         | 签名                            | 说明                                                           |
| ------------------------------------------ | ----------------------------- | ------------------------------------------------------------ |
| `@tool`                                    | 装饰器                           | 函数 → StructuredTool（name/description/args 自动生成）              |
| `tool.invoke(args)`                        | `(dict) -> Any`               | 直接执行工具（测试用）                                                  |
| `model.bind_tools(tools)`                  | `(list) -> _ChatModelBinding` | 绑定工具 schema 到模型（内部 = RunnableBinding，`invoke` 仍返回 AIMessage） |
| `resp.tool_calls`                          | `-> list[ToolCall]`           | 模型的工具声明（name/args/id）                                        |
| `ToolMessage(content, tool_call_id, name)` | 构造                            | 回传执行结果（`tool_call_id` 必须匹配 `tool_calls` 里的 id）               |

***

## 5. 进程线程模型

```
run_with_tools("还有几天到转正答辩？")
  ├─ 模型调用 #1（网络阻塞 1~3s）→ 返回 tool_calls
  ├─ days_until.invoke(...)（本地纯计算，~0ms）    ← 工具执行在主线程
  ├─ 模型调用 #2（网络阻塞 1~3s）→ 返回最终回答
```

* **每次工具调用 = 多一次模型往返**。1 个工具 = 2 次模型调用，延迟 ≈ 2 倍单次问答。
* 工具是本地函数 → 主线程直接跑；工具查外部 API → 也是网络阻塞（和模型调用同性质）。
* 高并发下多个工具调用可并行（`RunnableParallel`，第 12 篇讲），但要注意工具是否有副作用（如写库）。

***

## 6. 网络模型

```
用户 → POST /v1/messages（带 tools schema + 问题）
     ← tool_calls: {name: days_until, args: {...}}
  → 本地执行 days_until（0 网络）
用户 → POST /v1/messages（历史 + ToolMessage 结果）
     ← 最终回答
```

**本质**：工具调用是**两次标准模型请求**。第一次请求里多带了一份"工具说明书"（schema），模型选择要不要用；第二次请求把工具结果当普通消息发给模型。没有新协议。

***

## 7. 验证：跑起来

配套代码 `code/11_tools.py`（七幕）：

1. `@tool` 定义工具，打印 name/description/args（看工具的数据字典）；
2. `bind_tools` 后问一个需要算日期的问题，看模型返回 `tool_calls` 的结构；
3. 手动执行循环：模型声明 → 执行 → ToolMessage 塞回 → 最终回答；
4. 工具 vs RAG 分工对比；
5. **多工具**：两个工具（days\_until/days\_since），看模型按语义选哪个；
6. **失败重试**：multiply 触发安全限制报错，看模型自我纠正；
7. 边界。

```powershell theme={null}
cd enterprise-rag-course\code
python 11_tools.py
```

**预期输出（节选，实测）**：

```
===== ① @tool =====
工具名: days_until
参数 schema: {'target_date': {'title': 'Target Date', 'type': 'string'}}

===== ② bind_tools =====
tool_calls[0]:
  name = days_until
  args = {'target_date': '2026-10-01'}
  id   = tool-05d72c1bdf4b472595132cd5b7b1067d

===== ③ 手动循环 =====
  [执行工具] days_until({'target_date': '2026-10-01'}) → 57
模型最终回答: 距离 2026-10-01 的转正答辩还有 57 天。
共 2 次模型调用 ← 工具让模型'动手算'，而不是瞎编数字
```

***

## 7.5 进阶：多工具与失败重试

### 多工具：模型自己选

`bind_tools` 接受**多个**工具，模型按问题语义挑对的：

```python theme={null}
@tool
def days_until(target_date: str) -> int:
    """计算今天到目标日期还有几天（YYYY-MM-DD 格式）。"""
    ...

@tool
def days_since(start_date: str) -> int:
    """计算今天到指定日期已经过了多少天（YYYY-MM-DD 格式，用于倒推已用时）。"""
    ...

multi_model = model.bind_tools([days_until, days_since])

r = multi_model.invoke("入职 2026-06-01，到今天培训了多少天？")
r.tool_calls   # → [{'name': 'days_since', 'args': {'start_date': '2026-06-01'}, 'id': '...'}]
# 模型选了 days_since（算已过天数），不是 days_until（算未来天数）——描述写清楚它就能选对
```

**实测**：两个工具描述都写清楚用途，模型问"培训了多少天"就选 `days_since`，问"还有几天答辩"就选 `days_until`。**工具描述（docstring）是模型选工具的"说明书"**——描述含糊，模型就会选错。

### 失败重试：工具报错，模型自我纠正

工具抛异常**不中断对话**——把错误信息作为 `ToolMessage` 塞回去，模型自己调整：

```python theme={null}
@tool
def multiply(a: float, b: float) -> float:
    """乘法运算。结果绝对值超过 1 亿时报错（模拟生产安全限制）。"""
    result = a * b
    if abs(result) > 100_000_000:
        raise ValueError("结果超出允许范围（1 亿），请缩小数值或换个算法")
    return result

# 手动循环里包一层 try/except：
for tc in resp.tool_calls:
    try:
        content = str(multiply.invoke(tc["args"]))
    except ValueError as e:
        content = f"工具执行失败: {e}"   # ← 错误也走 ToolMessage
    messages.append(ToolMessage(content=content, tool_call_id=tc["id"], name=tc["name"]))
```

**实测**（问"123456 × 789012 =？"，结果超 1 亿触发报错）：

```
工具报错: 结果超出允许范围（1 亿），请缩小数值或换个算法
模型收到错误后: 抱歉，无法直接计算，因为计算结果超出了允许的范围（1 亿）。
  不过我可以帮您分析一下：123456 是 6 位数...
```

**要点**：错误信息要**说清楚问题 + 给可操作的纠正建议**（"请缩小数值"）。模型靠这句决定下一步——信息含糊，它只会重复同样的错误。

***

## 8. 边界

* **工具声明要花 token**——工具 schema 每次请求都带上，工具越多、描述越长越贵。
* **模型可能调错参数**——`args` 是模型猜的。生产环境要在执行前校验（`args` 类型检查），失败就返回错误信息给模型让它重试。
* **工具结果也进上下文**——工具返回超长内容会爆上下文，工具函数要控制返回长度。
* **安全红线**——**别让模型调有副作用的工具**（删除、写库、发邮件）。生产用白名单：只 bind 你允许的工具，且工具内部做权限校验。
* **`tool_calls` 为空的回答**——模型不一定每次都调工具，它可能直接回答。这是正常行为，你的循环要能处理"没有 tool\_calls"的情况（就是上面的 `if not resp.tool_calls`）。

***

## 推荐资料（延伸阅读）

* [LangChain 官方文档 · 工具](https://docs.langchain.com/oss/python/langchain/tools) —— 工具接口、ToolMessage 权威说明
* [LangChain 官方文档 · 模型工具调用](https://docs.langchain.com/oss/python/langchain/models#tool-calling) —— bind\_tools 与服务端工具

***

## 9. 未完待续

模型现在能"动手"了——但这引出一个生产级问题：

> 一次工具问答 = 多次模型调用（1 工具 = 2 次）。如果 100 个用户同时这么问，每个请求都阻塞 1\~3 秒，**你的服务扛得住吗？**

这就是第 12 篇：并发与网络模型——把 LangChain 的调度机制（线程池、事件循环、HTTP 连接）彻底拆开看。

→ [12 · 并发与网络模型](/doc/doc/enterprise-rag-course/12-并发与网络模型)
