Skip to main content

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 装饰器(把函数变成工具)

@tool 自动从函数签名 + docstring 生成三样东西(实测): 为什么 docstring 必须写清楚:模型靠 description 决定”什么时候该用这个工具”。写”计算两个日期差几天”比写”算天数”好——模型理解得越准,调用越对。

2.2 bind_tools(让模型知道有工具可用)

内部结构(实测)bind_tools 返回的是 _ChatModelBinding——它是 RunnableBinding 的子类,本质是**“原模型 + 额外参数”的绑定层**:
它干了三件事
  1. 把工具列表 [days_until] 转成模型厂商要的 tools schema(Anthropic/OpenAI 协议格式);
  2. 存进 kwargs,每次请求自动带上;
  3. 返回一个”绑定后的模型”——invoke() 的返回类型不变,还是 AIMessage,只是这个 AIMessage 可能带 tool_calls 而不是普通文本。
bind_tools 返回一个绑定了工具 schema 的模型绑定。之后调用它,模型在需要时返回 tool_calls 而不是普通回答

2.3 ToolCall(模型声明的数据结构)

注意:模型只”声明”要调工具,不执行。执行是你的责任——这就是”手动”的含义。

2.4 ToolMessage(工具执行结果回传)

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

3. 手动执行循环(核心代码)

循环三步(和记忆的”取历史→拼消息→写回”一样是固定骨架):
为什么不是 agent:循环是你写的 for,不是框架自动的。你控制轮数上限(防死循环)、决定执行哪些工具(白名单)、处理失败(try/except)。主动权在你——这正是本系列”不用 agent 也能做生产级”的哲学。

3.5 工具消息必须留在历史里(关键认知)

第 2 次调用前,messages 里实际装着什么(实测,2026-08):
为什么这三条缺一不可:模型第 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. 关键方法


5. 进程线程模型

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

6. 网络模型

本质:工具调用是两次标准模型请求。第一次请求里多带了一份”工具说明书”(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. 边界。
预期输出(节选,实测)

7.5 进阶:多工具与失败重试

多工具:模型自己选

bind_tools 接受多个工具,模型按问题语义挑对的:
实测:两个工具描述都写清楚用途,模型问”培训了多少天”就选 days_since,问”还有几天答辩”就选 days_until工具描述(docstring)是模型选工具的”说明书”——描述含糊,模型就会选错。

失败重试:工具报错,模型自我纠正

工具抛异常不中断对话——把错误信息作为 ToolMessage 塞回去,模型自己调整:
实测(问”123456 × 789012 =?“,结果超 1 亿触发报错):
要点:错误信息要说清楚问题 + 给可操作的纠正建议(“请缩小数值”)。模型靠这句决定下一步——信息含糊,它只会重复同样的错误。

8. 边界

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

推荐资料(延伸阅读)


9. 未完待续

模型现在能”动手”了——但这引出一个生产级问题:
一次工具问答 = 多次模型调用(1 工具 = 2 次)。如果 100 个用户同时这么问,每个请求都阻塞 1~3 秒,你的服务扛得住吗?
这就是第 12 篇:并发与网络模型——把 LangChain 的调度机制(线程池、事件循环、HTTP 连接)彻底拆开看。 12 · 并发与网络模型