LangChain 指南 · 第 10 章

工具调用

本章你将学会 - 为什么需要工具:模型的知识停在下训那天,而「拾光书屋今天几点关门」是今天的事——工具就是模型伸向外部世界的手和脚 - @tool 装饰器:一个普通 Python 函数如何三分钟变身「模型能看懂的工…

约 45 分钟18,086 字19 个代码示例

本章你将学会

  • 为什么需要工具:模型的知识停在下训那天,而「拾光书屋今天几点关门」是今天的事——工具就是模型伸向外部世界的手和脚
  • @tool 装饰器:一个普通 Python 函数如何三分钟变身「模型能看懂的工具」(name、description、参数 schema 全自动生成)
  • docstring 写法与 Pydantic args_schema:参数说明书写给谁看、怎么写才能让模型填参填得准
  • Tool.from_function:给「已经写好的现成函数」穿马甲的老钥匙
  • name / description 的讲究:模型选不选你的工具,全看这块「柜台小牌子」
  • bind_tools 完整循环:模型绑定工具 → invoke → 解析 AIMessage.tool_calls → 手动执行 → ToolMessage 回填 → 再调模型拿最终回答——这是 11 章 Agent 循环的手动版预习
  • 错误处理的黄金设计:工具报错返回字符串而不是抛异常,让循环活下来
  • tool_choice / parallel_tool_calls 这些 OpenAI 旋钮,到了 Ollama 这边哪些能拧、哪些是摆设

05 章讲 with_structured_output 时埋过一句伏笔:它的底层就是工具调用协议——把 schema 包装成「工具」,让模型以为自己在填参数。当时我们只借了协议的壳,这章把壳里的肉全端上来:让模型真正「会说话还能动手」。09 章攒下的 messages 管理经验(消息列表就是对话的全部记忆)在本章会直接复用——工具调用的每一轮,本质都是往这个列表里追加消息。

比喻:把模型想成拾光书屋的坐堂老掌柜——满腹经纶、过目不忘,但一辈子没离开过柜台。「今天进了什么新书?」「会员卡还剩几次借阅?」这些他答不上来,因为他出不去。工具就是他手下的跑腿伙计。老掌柜不亲自跑腿,他只开取货单(tool_calls:找哪个伙计、带什么参数),伙计跑完把回执(ToolMessage)递回来,他看了回执再答复客人。记住这个画面,本章所有 API 都能对号入座。

技术基线:Python + LangChain 1.x(langchain + langchain-ollama),本地 Ollama 跑 qwen3:4b。工具函数全部用纯本地数据(书屋藏书表、营业时间表),不需要联网;模型环节 Ollama 不在线时 demo 会自动降级。


0. 一张 JS → Python 对照表(先收藏)

JS 版课程写法 本章 Python 写法 备注
手写 tools 数组(type: "function" + function.name/description/parameters) @tool 装饰器自动生成 JS 要手写 JSON Schema,Python 从函数签名 + 类型标注 + docstring 自动推断
zodToJsonSchema(z.object({...})) class Xxx(BaseModel) 传给 args_schema Zod 的 Python 亲家是 Pydantic(05 章已混熟)
model.withConfig({ tools: [...] }) model.bind_tools([...]) JS 侧也有 .bindTools(),withConfig 绑 tools 属于早期写法;Python 的地道姿势是 bind_tools
result.choices[0].message.tool_calls ai_msg.tool_calls 返回的是 AIMessage,tool_calls 是现成的 list[dict]
call.function.name / JSON.parse(call.function.arguments) call["name"] / call["args"] 大差异:JS 的 arguments 是字符串要手动 parse;Python 的 args 已经是解析好的 dict
{ role: "tool", tool_call_id, content } ToolMessage(content=..., tool_call_id=...) 消息类接管了 role 的拼装
tool_choice: "none" / 强制指定函数 Ollama 不支持,参数传了也被忽略 绕路方案见第 8 节
parallel_tool_calls: false Ollama 同样不支持 是否并行发单,基本看模型自己的心情和能力
——(JS 版没讲) ToolException + handle_tool_error 框架级的「报错翻译」通道,见第 9 节

1. 为什么需要工具:老掌柜的边界

先看两个能把模型问住的问题:

拾光书屋今天营业到几点?
书屋里现在还有《夜航船》的库存吗?

不是模型笨——是它的知识凝固在下训那一刻。营业时间今天可能刚调整,库存每一分钟都在变,这类「实时性问题」靠参数里的旧记忆永远答不准。早年的解法是 Function Calling:让模型在回答不了时,声明「我需要调用某个函数,参数如下」,由外部程序去执行、再把结果喂回来。后来这套机制统一更名为 Tools(各厂商叫法略有差异,机制同源)。

完整的工具调用工作流是五步一循环:

1. 用户提问 ──────────► "书屋今天几点关门?"
                              ↓
2. 模型决策 ──► 不需要工具?直接回答(对话结束)
              └─► 需要工具?返回 tool_calls:get_store_hours()
                              ↓
3. 你的代码执行工具 ──► 本地函数真正跑一遍,拿到 "22:00"
                              ↓
4. 结果回填 ──► 包成 ToolMessage,连同历史一起再发给模型
                              ↓
5. 模型综合回执 ──► "书屋今天营业到 22:00,欢迎来夜读。"

一个必须刻进脑子的认知:模型从头到尾没有执行过任何工具。它只负责「下单」(说出工具名和参数),真正跑函数的永远是你的 Python 代码。所以工具函数写在本地、访问什么数据、怎么防注入,全在你的掌控之内——模型只是拿到了一张写满「可用工具清单」的说明书。

还有个容易被忽略的推论:这个循环是可以转多圈的。第 5 步的模型如果发现回执不够(比如先查库存、再查物流),它可以再发一轮 tool_calls。手动转圈是本章的练习题,让它自动转起来就是 11 章 Agent 的全部秘密。


2. @tool 装饰器:普通函数三分钟变身工具

作用:把一个带类型标注的普通函数,包装成模型能看懂的 BaseTool 对象——函数名变工具名、docstring 变描述、签名和类型标注变参数 schema,一份代码两份产出(Python 照常调用,模型照单选用)。

比喻:@tool 像给函数办了一张「上岗证」——证件上打印着姓名(name)、岗位说明(description)、携带物品清单(参数 schema)。模型招聘伙计时只看证件,不看人。

签名(langchain-core 1.x 实测,简化):

from langchain_core.tools import tool

def tool(
    name_or_callable: str | Callable[..., Any] | None = None,  # 直接装饰函数,或先传名字
    *,
    description: str | None = None,        # 不传就用 docstring
    args_schema: type[BaseModel] | None = None,  # 显式参数 schema(第 3 节)
    return_direct: bool = False,           # 结果直接返回、不再回炉模型(11 章细讲)
    parse_docstring: bool = False,         # 解析 Google 风格 docstring(第 3 节)
    response_format: Literal["content", "content_and_artifact"] = "content",
) -> BaseTool: ...

最小示例——拾光书屋的藏书检索工具:

from langchain_core.tools import tool


@tool
def search_books(keyword: str, limit: int = 3) -> str:
    """按关键词检索拾光书屋的藏书,返回书名和库存信息。

    Args:
        keyword: 检索关键词,可以是书名片段、作者名或主题词
        limit: 最多返回几条结果,默认 3
    """
    catalog = {
        "夜航船": "张岱 · 明代百科 · 库存 2 本",
        "陶庵梦忆": "张岱 · 散文回忆录 · 库存 1 本",
        "山月记": "中岛敦 · 短篇小说集 · 库存 3 本",
    }
    hits = [f"《{t}》{info}" for t, info in catalog.items() if keyword in t or keyword in info]
    return "\n".join(hits[:limit]) if hits else f"没有找到与「{keyword}」相关的藏书"

装饰完它就不再只是个函数了——体检查三件套:

print(search_books.name)          # search_books        ← 函数名即工具名
print(search_books.description)   # 整段 docstring      ← 模型看到的岗位说明
print(search_books.args)          # {'keyword': {'type': 'string'}, ...}  ← 参数 schema
print(search_books.invoke({"keyword": "张岱", "limit": 2}))
# 《夜航船》张岱 · 明代百科 · 库存 2 本
# 《陶庵梦忆》张岱 · 散文回忆录 · 库存 1 本

注意 .invoke() 收的是 dict(不是位置参数)——这正是后面模型回填 tool_calls 时的原生格式,两边天然对接。

docstring 写法的讲究:

  • 首行是金句。description 会整段发给模型,但首行权重最高——「干什么」一句话说清,别绕弯子。
  • Args: 段落按 Google 风格写(参数名: 描述,四空格缩进),是参数说明的正规位置。
  • 没有 docstring?@tool 会直接拒绝(error_on_invalid_docstring=True 时报错)——工具没有说明书等于伙计没有上岗证。

常见坑:

  • docstring 写给模型看,不是写给自己看。「检索藏书」和「按关键词检索书屋在售藏书,适用于读者找书的场景,返回书名、作者与库存」对模型是两个完全不同的工具。写文档时脑补的画面应该是:一个只读过这块小牌子的陌生人,要判断该不该找这个伙计。
  • @tool 装饰后调用方式变了。原函数 search_books("张岱") 的直呼方式失效(名字已被工具对象接管),要用 search_books.invoke({...})。想保留原函数,就把核心逻辑抽成普通函数、再包一层。
  • 类型标注不能省。def f(keyword): 没有 : str,schema 推断不出类型,infer_schema 会报错或退化。参数没默认值 = 必填(required),有默认值 = 可选——和 Pydantic 规则一致。
  • 函数名就是工具名,别用 f / tool1 这种敷衍名——模型选工具时名字是第一线索。

3. Pydantic 参数 schema:给参数配「说明书」

作用:第 2 节的自动推断给的是「参数骨架」(名字 + 类型),但参数的描述默认不在里面。模型填参填得准不准,很大程度取决于每个参数有没有一段像样的说明书——这正是原 JS 课程用 Zod describe() 解决的问题,Python 侧用 Pydantic Field。

两条路,按需选:

3.1 路线一:parse_docstring=True,说明书就地取材

from langchain_core.tools import tool


@tool(parse_docstring=True)
def search_books(keyword: str, limit: int = 3) -> str:
    """按关键词检索拾光书屋的藏书,返回书名和库存信息。

    Args:
        keyword: 检索关键词,可以是书名片段、作者名或主题词
        limit: 最多返回几条结果,默认 3
    """
    ...

打开这个开关后,Args: 段落会被拆出来贴到对应参数上:docstring 首行单独成为 description,每个参数的说明进入 schema 的对应字段。

3.2 路线二:显式 args_schema,说明书单独成册

from langchain_core.tools import tool
from pydantic import BaseModel, Field


class SearchBooksInput(BaseModel):
    keyword: str = Field(description="检索关键词,可以是书名片段、作者名或主题词")
    limit: int = Field(default=3, ge=1, le=10, description="最多返回几条结果,1 到 10 之间,默认 3")


@tool(args_schema=SearchBooksInput)
def search_books(keyword: str, limit: int = 3) -> str:
    """按关键词检索拾光书屋的藏书,返回书名和库存信息。"""
    ...

模型实际收到的,可以用一个透视函数看清(这就是发给模型的工具清单真身,等价于 JS 课程手写的那个 tools 数组):

import json
from langchain_core.utils.function_calling import convert_to_openai_tool

print(json.dumps(convert_to_openai_tool(search_books), ensure_ascii=False, indent=2))
# {
#   "type": "function",
#   "function": {
#     "name": "search_books",
#     "description": "按关键词检索拾光书屋的藏书,返回书名和库存信息。",
#     "parameters": {
#       "properties": {
#         "keyword": { "description": "检索关键词,...", "type": "string" },
#         "limit": { "default": 3, "description": "最多返回几条结果,...", "type": "integer" }
#       },
#       "required": ["keyword"],
#       "type": "object"
#     }
#   }
# }

required 列表由「有没有默认值」决定——没有默认值的参数进了 required,模型填参时漏了它,pydantic 校验会在 .invoke() 时当场拦下。

参数校验如何影响模型行为:schema 里的每条约束(类型、default、ge/le 边界、description 里的措辞)都会随工具清单发给模型,模型生成参数时是对着这份 schema 填表的。所以「说明书」写得越具体,参数回收质量越高——这和 05 章 Field(description=...) 影响结构化输出质量是同一个道理,因为底层是同一套协议。

常见坑:

  • 默认 parse_docstring=False 时,Args: 段落整段躺在 description 里、参数却裸奔——参数没有各自的描述,模型只能靠参数名瞎猜。这是从 JS 资料迁过来最容易踩的差异:Zod 的 .describe() 在 Pydantic 对应物是 Field(description=...),不会自动从 docstring 生成。
  • 两条路别同时铺:args_schema 优先级高,铺了它 docstring 的 Args: 段就只是注释。
  • 校验是收货时验的:模型传了 limit: "三",.invoke() 才抛 ValidationError——模型自己不验,框架替你把关。验不过怎么办?看第 9 节的错误处理设计。
  • args_schema 里写了 ge=1, le=10 这类数值边界,会如实进入 JSON Schema——多数模型会遵守,但不是硬保证,关键业务在函数体内再验一次。

4. Tool.from_function:给现成函数穿马甲的老钥匙

作用:函数已经写好了(比如团队里的公共库函数),不想在它头上加装饰器、改 docstring——Tool.from_function 让你在外面给它套上工具的壳。这是 LangChain 早期的经典做法,至今还能从 langchain_core.tools 里原样导入。

签名(1.x 实测):

from langchain_core.tools import Tool

Tool.from_function(
    func: Callable[..., Any] | None,   # 现成的函数
    name: str,                          # 必须显式起名
    description: str,                   # 必须显式写描述
    return_direct: bool = False,
    args_schema: ArgsSchema | None = None,   # 传了才能收结构化参数
) -> Tool

最小示例——把一个查询营业时间的现成函数包装成工具:

from langchain_core.tools import Tool, StructuredTool


def _hours(text: str) -> str:   # 注意:单输入,收一个字符串
    return "拾光书屋今日营业时间:9:00 - 22:00"


legacy_tool = Tool.from_function(
    func=_hours,
    name="get_store_hours",
    description="查询拾光书屋今日的营业时间段",
)
print(legacy_tool.invoke({"tool_input": "今天"}))   # 单输入工具的参数名固定叫 tool_input

它的现代接班人是 StructuredTool.from_function——签名推断、多参数、Pydantic schema 全套都有,@tool 装饰器的功能它基本都继承了:

structured = StructuredTool.from_function(
    func=_hours,
    name="get_store_hours",
    description="查询拾光书屋今日的营业时间段",
)

什么时候还用得上老钥匙?函数是第三方写的不能改、或者想把「函数定义」和「工具包装」放在两个文件里的时候。新代码一律推荐 @tool——少写两个必填参数,少踩一个单输入的坑。

常见坑:

  • Tool 是「单输入」时代的产物:不传 args_schema 时,它把整个输入当作一个字符串参数(tool_input),多参数函数塞进去会被拍扁。要多参数,要么传 args_schema,要么直接用 StructuredTool.from_function。
  • name 和 description 在这条路上是必填项——没有 docstring 可以白嫖,全靠自己写。名字还必须和后续手动执行时查表用的 key 对得上(第 7 节)。

5. name 与 description 的讲究:柜台上的小牌子

原课程有一句话值得原样记住精神:description 是模型决定「是否调用这个工具」的唯一信息源。模型没读过你的源码,没见过你的数据库——它选工具时眼前只有一块小牌子:工具叫什么名字、干什么、什么时候该找它。

好 description 的三段式(对着自查):

  1. 干什么:核心功能一句话(「按关键词检索书屋藏书」);
  2. 什么时候用:适用场景(「读者询问有没有某本书、库存多少时使用」);
  3. 边界与限制:不适用场景、返回什么(「只覆盖在售藏书,不含古籍仓库;返回书名、作者与库存数量」)。

多工具注册时,小牌子决定分工。给书屋挂两个长得很像的工具:

@tool
def search_books(keyword: str) -> str:
    """按关键词检索书屋在售藏书。读者找书、查库存时使用。"""


@tool
def search_events(keyword: str) -> str:
    """按关键词检索书屋的线下活动。读者问读书会、签售、夜读沙龙时使用。"""

参数一模一样,模型凭什么选对?全靠 description 里那句「什么时候用」。牌子含糊(两个都写「按关键词检索」),误选就是家常便饭——而误选的代价是真金白银:执行了错误的工具、浪费一轮循环、拿回风马牛不相及的回执。

name 的三条讲究:

  • 动词或动宾开头:search_books、get_store_hours、reserve_seat,一眼看懂动作;
  • 别怕长,怕歧义:search 不如 search_books;get_info 是反面教材;
  • 全局唯一:两个工具重名,后面手动执行时查表(第 7 节的 dict)会互相覆盖,模型下单也会错乱。

常见坑:

  • 描述里写「获取数据」这种空话——模型不是猜谜选手,牌子越具体,下单越准。
  • 描述与实现不符(写着「查库存」,实际只查书目)——模型会理直气壮地在错误场景下单,锅最终扣在你头上。
  • 改了工具行为却忘了同步 description——工具清单发给模型的是 description,不是源码。文档漂移在工具调用里的杀伤力比普通项目大得多。

6. bind_tools:把工具箱挂上模型

作用:把若干工具「绑定」到一个聊天模型上,返回一个带工具的新 Runnable——之后每次 invoke,工具清单都会随请求一起发给模型,模型便拥有了「下单」的能力。这是 JS 课程里 withConfig({ tools }) 的 Python 对应物(JS 侧的 .bindTools() 是同一个东西的现代名)。

比喻:bind_tools 像给老掌柜的柜台挂上一排呼唤铃——每个铃铛上刻着伙计的名字和特长。掌柜回答不了的问题,他会按铃并附一张取货单;但他依然只是按铃,跑腿的还是你的代码。

签名(langchain-ollama 1.x 实测):

from langchain_ollama import ChatOllama

model = ChatOllama(model="qwen3:4b", temperature=0)

bound = model.bind_tools(
    tools: Sequence[dict | type | Callable | BaseTool],  # 工具列表,格式很宽容
    *,
    tool_choice: dict | str | Literal["auto", "any"] | bool | None = None,  # Ollama 忽略(第 8 节)
    **kwargs: Any,
) -> Runnable[LanguageModelInput, AIMessage]

最小示例——绑定 + 首轮调用 + 解析下单单据:

from langchain_core.messages import HumanMessage
from langchain_ollama import ChatOllama

model = ChatOllama(model="qwen3:4b", temperature=0)
bound = model.bind_tools([search_books, get_store_hours])   # 第 2、4 节定义的工具

ai_msg = bound.invoke([HumanMessage(content="书屋今天营业到几点?")])

print(type(ai_msg))       # AIMessage
print(ai_msg.content)     # ''(或思考性文字)——模型这轮不说话,只下单
print(ai_msg.tool_calls)  # 下单单据,见下

tool_calls 是本章最重要的数据结构,逐字段拆解:

[{
    "name": "get_store_hours",      # 找哪个伙计 —— 对应你注册的工具名
    "args": {},                     # 带什么参数 —— 已解析好的 dict(省了 JSON.parse)
    "id": "call_9f2c...",           # 单据编号 —— 回执 ToolMessage 必须原样带回
    "type": "tool_call",            # 固定标记
}]

和 JS 版对照着记两个差异:

  1. JS 里参数是 function.arguments 的 JSON 字符串,用前要 JSON.parse();Python 的 args 已经是 dict,拿来就用。
  2. JS 里看 finish_reason: "tool_calls" 判断这轮以下单收尾;Python 侧约定俗成看 ai_msg.tool_calls 是否非空,更直接。

绑定是「生成新对象」,不动原模型——和 03 章 with_config、05 章 with_structured_output 一脉相承的不可变风格。你可以同一个模型绑出多个「专用分身」:绑检索工具的「找书分身」、绑活动查询的「活动分身」,互不干扰。

bind_tools 的入参很宽容:BaseTool 对象(@tool 的产物)、Pydantic 类、OpenAI 格式的裸 dict、甚至普通函数都能塞——内部统一走 convert_to_openai_tool 转换。但普通函数没有 docstring 时描述是空的,模型无从判断——优先用 @tool 的成品。

常见坑:

  • 绑了工具 ≠ 模型必须用。默认策略是「auto」:模型自己判断该不该下单。问「推荐一本书」它可能直接回答、连工具都不碰——这是正常行为,代码要有分支(第 7 节的第一道判断)。
  • tool_calls 是列表——一次可能有多张单(并行调用),用 for call in ai_msg.tool_calls 遍历,别只取 [0]。
  • 别把「绑工具」和 05 章 with_structured_output 弄混:前者是真执行(模型下单、你跑函数、结果回炉),后者是借协议要结构(模型填表、直接拿结果)。一个管动手,一个管动嘴。

7. 完整循环:手动跑通「下单 → 执行 → 回执 → 再答」

这是全章的主菜,也是 11 章 Agent 循环的手动版预习。JS 课堂代码完整演示了这条链路,Python 版一步不少——而且因为 args 已是 dict、ToolMessage 接管了消息拼装,代码更短。

比喻串场:客人发问 → 老掌柜开单(tool_calls)→ 你把单子递给伙计(查表执行)→ 伙计写回执(ToolMessage,编号对齐)→ 掌柜看回执答客人(再 invoke)。六个步骤,一步都不能少:

import json

from langchain_core.messages import AIMessage, HumanMessage, ToolMessage


def run_tool_loop(bound_model, question: str, tools_by_name: dict) -> str:
    """手动版的工具调用循环:11 章 Agent 循环的预习课。

    Args:
        bound_model: bind_tools 之后的模型(或任何兼容 invoke 的 Runnable)
        question: 用户的原始问题
        tools_by_name: 工具名 -> 工具对象 的查找表
    """
    # ① 用户提问
    messages: list = [HumanMessage(content=question)]

    # ② 模型第一轮:决定是否下单
    ai_msg: AIMessage = bound_model.invoke(messages)
    messages.append(ai_msg)                      # ★ 关键:把带 tool_calls 的 AIMessage 存档

    # ③ 分支:模型没下单,直接就是最终回答
    if not ai_msg.tool_calls:
        return str(ai_msg.content)

    # ④ 遍历单据:查表 → 执行 → 包回执
    for call in ai_msg.tool_calls:
        name: str = call["name"]
        args: dict = call["args"]
        call_id: str = call["id"]

        tool_fn = tools_by_name.get(name)
        if tool_fn is None:                      # 模型点名了不存在的伙计
            observation = f"错误:工具 {name} 不存在,可用工具:{list(tools_by_name)}"
        else:
            try:
                observation = str(tool_fn.invoke(args))     # 真正执行的是你的代码
            except Exception as exc:                         # 兜底:报错也变成回执(第 9 节)
                observation = f"错误:{type(exc).__name__}: {exc}"

        print(f"已执行 {name}({json.dumps(args, ensure_ascii=False)}) -> {observation}")
        # ⑤ 回执入档:tool_call_id 必须原样对齐单据编号
        messages.append(ToolMessage(content=observation, tool_call_id=call_id))

    # ⑥ 第二轮:模型看着完整对话(含回执)给出最终回答
    final_msg: AIMessage = bound_model.invoke(messages)
    return str(final_msg.content)


answer = run_tool_loop(bound, "书屋今天营业到几点?还有张岱的书吗?", {
    "search_books": search_books,
    "get_store_hours": get_store_hours,
})
print(answer)

跑起来的样子(示意):

已执行 get_store_hours({}) -> 拾光书屋今日营业时间:9:00 - 22:00
已执行 search_books({"keyword": "张岱"}) -> 《夜航船》张岱 · 明代百科 · 库存 2 本 ...
书屋今天营业到 22:00;张岱的《夜航船》《陶庵梦忆》都有库存,欢迎来挑。

注意用户一句问话里带了两件事,模型一口气开了两张单(tool_calls 列表里两项)——这就是并行工具调用,两家伙计各跑各的,回执按 tool_call_id 各自对账。

为什么这段代码是 11 章的预习课:把上面的「一轮」套进 while 循环、允许模型多次下单直到不再下单,再配一个「什么算完成」的判断——那就是 Agent。你在此处手写的每一行,届时都有框架代劳;但循环里发生什么,永远值得亲手摸一遍。

常见坑:

  • 忘了把 ai_msg append 进 messages(②步的 ★)。ToolMessage 是「对某张单据的回执」,单据本身不在对话里,模型看不懂回执在回什么——多数厂商 API 直接报 400 参数错误。这是全链路翻车率最高的一步。
  • tool_call_id 抄错。回执的编号必须和单据上的 call["id"] 一字不差,多张单时张冠李戴,模型会把库存结果安到营业时间头上。
  • 消息顺序不能乱:user → ai(tool_calls) → tool → tool → ai(最终)。回执必须紧跟在它回应的那张单据之后,中间不能插别的消息。
  • tools_by_name 查表 miss 别抛异常。模型偶尔会幻觉出不存在的工具名——像示例那样回一句「工具不存在」的字符串,模型下一轮会自己修正;抛异常则是整个循环当场暴毙(第 9 节的主旨)。
  • 执行工具时别信任模型给的参数。args 来自模型生成,类型大体可靠(有 schema 兜底),但业务合法性(比如 limit=999)要自己在函数里把关。

8. 配置旋钮:tool_choice 与 parallel_tool_calls 的现实

原 JS 课程在 OpenAI 接口上演示了三个进阶需求:禁止调用工具、强制调用某个工具、注册多个工具。前两个靠 tool_choice 旋钮,到了 Ollama 这边要重新面对现实。

8.1 tool_choice:Ollama 的摆设参数

ChatOllama.bind_tools 的签名里确实有 tool_choice 参数(支持 "auto" / "none" / "any" / 指定函数名),但源码里明确注释着:该参数当前被忽略,Ollama 不支持。传了不报错,但也不生效——这是从 OpenAI 代码迁移过来最容易「以为生效了」的暗坑。

对照着 OpenAI 生态的三个用法,看 Ollama 侧的绕路方案:

需求 OpenAI 写法(JS 课程) Ollama 侧的现实做法
禁止调用(tool_choice: "none") 注册了但不许用 干脆不 bind——不绑工具的模型天然只能用嘴回答;或换用没绑工具的裸模型分身
强制调用(指定函数名) tool_choice: {type: "function", function: {name: ...}} 换思路:固定调用、固定结构的场景,本质是要「填表」——用 05 章的 with_structured_output(Model, method="function_calling"),它底层就是把 schema 包成工具并强制走填表流程;或把「必须先调用 X」写进 system 消息软约束
自动选择(默认) tool_choice: "auto" 默认行为,无需配置

原课程给「强制调用」列的场景(写日志埋点、强制翻译、表单信息抽取、统一 SQL 生成)其实有个共同点:要的不是模型的判断,而是模型的格式。这类需求在 Python 侧几乎都该路由到 05 章的结构化输出,而不是硬掰工具调用——同一个底层协议,选对入口。

8.2 parallel_tool_calls:同样不支持

OpenAI 的这个旋钮关掉后,模型一次只开一张单(但不保证整轮就此收束,下一轮仍可能继续开单——原课程特意强调过这个语义)。Ollama 侧没有这个开关:一次回几张单,基本取决于模型自己的习惯(qwen3 系列常一口气开多张)。代码层面正确的姿势是:永远用 for 遍历 tool_calls,单张多张都能接住——第 7 节的循环天然免疫。

常见坑:

  • 把「忽略」当「生效」。bind_tools([...], tool_choice="none") 在 Ollama 上静默失效,模型照样下单——想验证某个旋钮,务必打日志看真实行为,别信参数名。
  • 强制工具的软约束(写进提示词「必须先调用 search_books」)对小模型只是「强烈建议」,qwen3:4b 偶尔会无视——重要流程别押注在提示词上。
  • 这些旋钮是厂商接口差异,不是 LangChain 差异。换回 ChatOpenAI 时 tool_choice 立刻恢复生效——写跨模型的代码时,这类参数要按集成逐个确认。

9. 工具报错:返回字符串,别抛异常

先立设计观:工具循环是一个「模型 ↔ 代码」的对话,不是一段「程序员 ↔ 代码」的程序。程序里抛异常天经地义——异常是给开发者看的;但循环里抛异常,砸的是整场对话:模型没了回执、循环断在半路、用户看到一屏 traceback。

比喻:伙计跑空一趟(书卖完了、接口超时了),正确动作是写张字条递回柜台——「《夜航船》今日售罄,预计周三补货」。掌柜看了字条,可以换家店问、或如实答复客人。掀桌子(抛异常)是最差的选择:掌柜、客人、伙计全僵在原地。

所以黄金法则是:工具的报错,也是给模型的一种「返回值」——用字符串描述发生了什么、可能怎么办,让循环活下去。

9.1 方案一:函数体内 try/except,字条自己写(最直接)

from langchain_core.tools import tool


@tool
def lookup_member_card(member: str) -> str:
    """查询会员的借阅卡信息:剩余借阅次数和有效期。

    Args:
        member: 会员姓名或卡号
    """
    cards = {"陈读者": "剩余 3 次,有效期至 2026-12-31"}
    try:
        return cards[member]
    except KeyError:
        return f"查询失败:找不到会员「{member}」,请确认姓名或改用卡号重试"

报错信息写给模型看:说清发生了什么(找不到)、给出下一步建议(换卡号)。模型下一轮看到这条回执,会自然地向用户追问卡号——循环没有断,体验没有崩。

9.2 方案二:ToolException + handle_tool_error,字条交给框架翻译

如果工具是别人的代码、不方便在体内包 try/except,LangChain 提供了框架级通道:工具体内抛专用的 ToolException,构造工具时配 handle_tool_error,框架会把异常翻译成字符串返回:

from langchain_core.tools import StructuredTool, ToolException


def _lookup(member: str) -> str:
    raise ToolException(f"查无此人:{member}")


safe_tool = StructuredTool.from_function(
    func=_lookup,
    handle_tool_error="查询失败:会员不存在,建议改用卡号重试",   # 字符串:固定文案
)
# handle_tool_error 也可以是函数:拿异常对象,动态生成字条
# handle_tool_error=lambda err: f"[查询失败] {err};建议改用卡号重试"

print(safe_tool.invoke({"member": "路人甲"}))   # 查询失败:会员不存在,建议改用卡号重试

9.3 1.x 的硬边界:handle_tool_error 只认 ToolException

这是从旧资料迁移最容易翻车的点,实测行为(langchain-core 1.x):

  • 工具体内抛 ToolException + 配了 handle_tool_error → ✅ 翻译成字符串返回;
  • 工具体内抛 ToolException、没配 handle_tool_error → ❌ 照样抛出(配置才是开关,异常类型只是门票);
  • 工具体内抛普通异常(KeyError / ValueError…),配了 handle_tool_error 也不拦 → ❌ 直接穿透抛出。早期版本拦所有异常,1.x 收紧成只认 ToolException——旧文章里「配了就能兜底」的说法已过时;
  • 模型给的参数不合法(类型对不上 schema)→ 抛 pydantic 的 ValidationError,走的是另一个开关 handle_validation_error,与 handle_tool_error 互不干涉。

所以工程上的组合拳是:体内 try/except 兜业务错误(字条内容最可控),ToolException + handle_tool_error 兜漏网之鱼(防止第三方代码裸抛),循环层再包一层 try/except(第 7 节④步)作为最后防线——三层下来,循环想断都难。

常见坑:

  • 字条别只写「出错了」。"error" 三个字母对模型毫无信息量——写清哪个环节、什么原因、怎么办,模型才能把话接下去。
  • 报错字符串里别带敏感信息(连接串、内部路径、堆栈)。它是要发给模型的——等于贴在了对话里。
  • 别用返回值里的「错误」伪装成功。字条开头的「查询失败:」这类明确标记很重要,让模型和日志都能一眼区分正常结果与异常回执。

10. 本章小结

一张表带走全章:

你要做什么 用什么 一句话
普通函数变工具 @tool + docstring + 类型标注 name/description/schema 全自动
参数带说明书 args_schema=PydanticModel 或 parse_docstring=True Field(description=...) 直接影响填参质量
包装现成函数 StructuredTool.from_function(老 Tool.from_function 仍可用) 老钥匙是单输入,新钥匙全功能
挂工具上模型 model.bind_tools([...]) 返回新的 Runnable,不动原模型
解析模型下单 ai_msg.tool_calls → list[dict](name/args/id/type) args 已是 dict,省了 JSON.parse
回填执行结果 ToolMessage(content=..., tool_call_id=...) 编号对齐单据,顺序紧跟其后
完整循环 手写六步(第 7 节) 11 章 Agent 循环的手动版
禁止/强制调用 Ollama 不支持 tool_choice;强制场景改走 with_structured_output 厂商旋钮按集成逐个确认
工具报错 返回字符串(体内 try/except / ToolException + handle_tool_error) 字条递回柜台,别掀桌子

三个心智锚点:

  1. 模型只下单,不执行——tool_calls 是取货单,跑腿的永远是你的 Python;安全、数据、副作用全在你手里。
  2. messages 列表是对话的全部真相——带单据的 AIMessage、对编号的 ToolMessage,一个都不能少、一个都不能乱序(09 章的经验在这里直接变现)。
  3. 工具协议是一鱼两吃——要结构走 with_structured_output(05 章),要执行走 bind_tools(本章);前者是后者的特例,Agent(11 章)是后者的全自动版。

配套 demo(本章 demo/ 目录):

  • demo_tool.py:单文件演示 @tool 定义 → 参数 schema 对比 → Tool.from_function → bind_tools → tool_calls 解析 → 手动执行循环 → 错误处理全链路。工具全部是纯本地函数(查书屋藏书、查营业时间),离线可跑;模型环节自动探测本地 Ollama——在线走真模型(qwen3:4b),不在线自动降级为固定的假 AIMessage.tool_calls,解析与执行链路照样完整演示。设 USE_MODEL=1 可强制真模型、USE_MODEL=0 强制离线。

下一章:11 · Agent