第 8 章 给图装上手脚:工具调用
本章目标:让图里的节点具备"调用外部能力"的本事——查岗位库、查公司背景。核心是 ToolNode 与"模型节点 ↔ 工具节点"的循环结构,也就是 ReAct 模式在 LangGraph 中的标准落地
本章目标:让图里的节点具备"调用外部能力"的本事——查岗位库、查公司背景。核心是
ToolNode与"模型节点 ↔ 工具节点"的循环结构,也就是 ReAct 模式在 LangGraph 中的标准落地。
8.1 模型的知识边界
评估进行到深处,一个新的问题浮出水面。
简历写着"曾在深澜科技任职"。这家公司做什么的?规模多大?技术栈是否匹配?模型不知道——训练数据里大概率没有这家公司的信息,编一个出来倒是毫不费力。同样,"岗位 JD 的最新版本是什么"也超出了模型的知识边界——JD 存在你的业务数据库里,实时变化。
这类问题的解法是老生常谈的 工具调用(function calling):把"查公司背景"做成一个函数,告诉模型这个函数的存在,模型在需要时输出一个结构化的调用请求,由你的代码执行函数、把结果回传给模型。你如果已经熟悉 LangChain 的 @tool 装饰器和 bind_tools,这一步没有任何新东西。
真正的新问题在图的层面:工具调用天然是个循环——模型可能连续调用多个工具(先查 JD,再查公司),每次调用后要回到模型继续推理,直到模型认为信息足够、输出最终结论。把这个循环画进图里,正是 LangGraph 的看家本领。
8.2 ReAct 循环的图结构
先把目标结构画出来。这就是经典的 ReAct(Reason + Act)模式:
结构要素逐个看:
- model 节点:把当前消息列表喂给绑定了工具的模型。模型的回复可能是最终答案(普通 AIMessage),也可能是一个或多个工具调用请求(带
tool_calls属性的 AIMessage)。 - ToolNode:LangGraph 内置节点,接收消息列表,执行其中所有未处理的
tool_calls,把每个工具的执行结果作为ToolMessage追加进消息列表。 - 条件边
route_after_model:模型回复里带tool_calls就去 ToolNode,否则去END。 - ToolNode → model 的回边:工具结果回传后,模型基于新信息继续推理——循环由此闭合。循环出口在模型自己手里(不再发起工具调用时)。
注意这个结构与前几章的两个呼应:循环出口是模型行为驱动的条件边,不是业务计数——但 recursion_limit 兜底依然适用(模型发疯式无限调工具时,第 16 章的容错策略接管)。
8.3 完整 demo:能查信息的评估节点
可运行的完整实现。两个工具:查公司背景、查岗位 JD(内存假数据,无需外部服务):
import os
from typing import Annotated, TypedDict
from dotenv import load_dotenv
from langchain_core.messages import AnyMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
load_dotenv()
# ---- 1. 定义工具 ----
COMPANIES = {
"深澜科技": {"scale": "500-1000人", "field": "跨境支付基础设施", "stack": "Java/Go 为主"},
"星河互联": {"scale": "2000+人", "field": "本地生活服务平台", "stack": "Python 微服务"},
}
JOBS = {
"python-backend": {
"title": "Python 后端工程师",
"requirements": "5 年以上经验;精通 Python/FastAPI;有高并发系统经验",
},
}
@tool
def lookup_company(name: str) -> str:
"""查询指定公司的背景信息:规模、业务领域、技术栈。
Args:
name: 公司全名,例如「深澜科技」
"""
info = COMPANIES.get(name)
return f"{name}:{info}" if info else f"未查询到公司「{name}」的背景信息"
@tool
def get_job_requirements(job_id: str) -> str:
"""按岗位 ID 查询最新的岗位 JD。
Args:
job_id: 岗位标识,例如「python-backend」
"""
job = JOBS.get(job_id)
return f"{job['title']} 的要求:{job['requirements']}" if job else f"岗位 {job_id} 不存在"
tools = [lookup_company, get_job_requirements]
# ---- 2. 状态:消息列表(工具调用是消息层面的协作) ----
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
# ---- 3. model 节点 ----
llm = ChatOpenAI(
model=os.getenv("LLM_MODEL", "gpt-4o-mini"),
temperature=0,
).bind_tools(tools) # 关键:把工具说明绑定给模型
SYSTEM_PROMPT = """你是资深技术招聘官,负责评估候选人简历。
评估前,如需了解候选人曾任职公司的背景或岗位最新要求,主动调用相应工具。
信息充分后,输出最终评估结论(匹配度 + 三条理由)。"""
def call_model(state: AgentState) -> dict:
from langchain_core.messages import SystemMessage
messages = [SystemMessage(SYSTEM_PROMPT)] + state["messages"]
response = llm.invoke(messages)
return {"messages": [response]} # add_messages:追加
# ---- 4. 路由函数:模型是否要调工具 ----
def route_after_model(state: AgentState) -> str:
last = state["messages"][-1] # 最新一条是模型的回复
return "tools" if last.tool_calls else END
# ---- 5. 组装图 ----
builder = StateGraph(AgentState)
builder.add_node("model", call_model)
builder.add_node("tools", ToolNode(tools)) # 内置节点:执行所有 tool_calls
builder.add_edge(START, "model")
builder.add_conditional_edges("model", route_after_model, {"tools": "tools", END: END})
builder.add_edge("tools", "model") # 回边:循环闭合
graph = builder.compile()
# ---- 6. 运行 ----
from langchain_core.messages import HumanMessage
result = graph.invoke({
"messages": [HumanMessage(
"候选人李思远:6 年后端经验,Python/FastAPI/PostgreSQL,"
"此前在深澜科技负责支付网关。请对照 python-backend 岗位评估。"
)]
})
print(result["messages"][-1].content)
运行时消息列表的演变过程(add_messages 逐步追加)值得想象一遍:
[HumanMessage] 用户请求
[AIMessage(tool_calls=[lookup_company])] 模型:先查公司
[ToolMessage(公司背景)] ToolNode 执行结果
[AIMessage(tool_calls=[get_job_requirements])] 模型:再查 JD
[ToolMessage(JD 内容)] ToolNode 执行结果
[AIMessage(最终评估结论)] 模型:信息够了,收工
两次工具调用、三轮模型推理、循环两次后自然终止——图结构一行没变,行为完全由模型驱动。
8.4 ToolNode 帮你做了什么
ToolNode 是内置节点,职责朴素但省掉大量样板代码:
- 扫描消息列表里最后一条 AIMessage 的
tool_calls(一条 AIMessage 可以携带多个调用请求,全部并行执行) - 按名称匹配注册的工具,传入参数执行
- 为每个调用生成一条
ToolMessage(带tool_call_id,与请求一一对应),追加进消息列表
它本质上就是一个"消息进、消息出"的普通节点——与你自己写的 def tools_node(state) -> dict 等价。手工实现一遍是理解机制的绝佳练习(见练习 3),生产中直接用 ToolNode。
两个高频定制点:
工具抛异常时:默认行为是把异常信息包成 ToolMessage 返回给模型(模型能看到失败原因并决定重试或换路)。handle_tool_errors=False 可改为直接抛出,让第 16 章的容错机制接管。
工具外再包一层:有时想在工具执行前后做日志、鉴权。可以用 LangChain 的 Runnable 对象代替裸函数注册进 ToolNode——ToolNode 对每个工具执行 .invoke(),Runnable 的组合能力(如 tool | 后处理)因此可用。
8.5 工具的输入来源:状态注入(进阶)
目前的工具参数全部来自模型生成(公司名、岗位 ID 由模型从简历/对话里提取)。但有一类参数模型给不了,也不该让它给——比如当前用户 ID、当前批次上下文。这类参数要用 InjectedState 从图状态注入,模型完全看不见:
from typing import Annotated
from typing_extensions import NotRequired
from langchain_core.tools import InjectedToolArg
from langgraph.prebuilt import InjectedState
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
current_user: NotRequired[str] # 不想让模型染指的字段
@tool
def submit_evaluation(
conclusion: str, # 模型提供
state: Annotated[dict, InjectedState], # 引擎注入:模型看不见此参数
) -> str:
"""提交最终评估结论。
Args:
conclusion: 评估结论文本
"""
user = state["current_user"]
return f"已代表 {user} 提交评估:{conclusion}"
InjectedState 标记的参数不会出现在给模型的工具 schema 里,调用时由引擎从当前状态注入。同理还有 InjectedToolArg(调用方手工注入)——第 14 章人在回路会用到它。记住这个分类:模型提供的参数走 schema,环境提供的参数走注入。鉴权、审计、租户隔离这类横切关注点,永远不该信任模型来传。
常见坑与排错
-
模型从不调用工具。排查顺序:
bind_tools忘了调(模型根本不知道工具有存在);工具 docstring 太含糊(模型不知道何时该用);system prompt 没有引导(模型倾向直接回答)。工具的 docstring 是写给模型看的说明书,值得像写 prompt 一样认真打磨。 -
模型调工具的参数是幻觉。比如把公司名写成简历里不存在的企业。工具内部要做参数校验,"未查询到"本身就是一种合法返回值——模型看到后会自我修正(这正是 8.4 提到的错误回传设计的价值)。
-
ToolMessage 的 tool_call_id 对不上。自己手工实现工具节点时(不用 ToolNode)最容易犯:每个 ToolMessage 必须携带对应 tool_call 的 ID,模型靠它把请求与结果配对。ID 错乱会导致下一轮模型请求直接 4xx。
-
无限工具循环。模型陷入"查了又查"的怪圈直到 recursion_limit 爆炸。三层治理:prompt 里明确"信息足够即收束";工具返回里加引导语("以上信息已足够评估");recursion_limit 兜底 + 第 16 章的重试策略。
-
把工具结果塞进普通字段而非 messages。工具调用的协作协议是消息层面的——ToolMessage 追加进消息列表,模型下一轮自然看到。把结果写进自定义状态字段、再在 prompt 里手动拼接,是重新发明一个更脆弱的协议。
-
在工具里做长任务。工具是同步执行的一环,一次几十秒的调用会把整个超步卡住。长任务应当拆成独立节点(或异步工具 + astream),工具只做快查询。
动手练习
-
给 demo 增加第三个工具
lookup_salary_band(company: str):查询某公司的薪资带。观察模型如何决定调用顺序——它是先查薪资还是先查背景?多跑几次(temperature 调高到 0.7)看决策路径是否稳定。 -
用
graph.stream(...)跑一遍 8.3 的 demo,把每个事件打印出来。数一数 model 和 tools 节点各执行了几次,与 8.3 末尾的消息演变对照。 -
不用
ToolNode,手工实现tools_node:从最后一条 AIMessage 读tool_calls,逐个 dispatch、生成带正确tool_call_id的 ToolMessage。实现完再回头看 ToolNode,体会它省掉的都是什么。
本章速查
| API / 概念 | 一句话 |
|---|---|
@tool 装饰器 |
函数 → 工具:docstring 即模型说明书 |
llm.bind_tools(tools) |
把工具 schema 绑定给模型 |
AIMessage.tool_calls |
模型的工具调用请求(可多个) |
ToolNode(tools) |
内置节点:执行所有 tool_calls,回填 ToolMessage |
route 模式 |
last.tool_calls 非空 → tools;否则 → END |
回边 tools → model |
ReAct 循环的闭合边 |
InjectedState |
参数由引擎从状态注入,模型不可见 |
handle_tool_errors |
工具异常时回传模型还是向上抛(默认回传) |
| 参数分界 | 模型提供的走 schema,环境提供的走注入 |