大模型本身不具备调用外部系统的能力。它能记住上下文,能生成通顺文字,但一旦需要获取实时数据、操作数据库、调用第三方服务,它的能力边界就出现了。LangGraph 工具调用(Tool Calling)正是为了解决这个缺口:让语言模型在对话过程中产出结构化的“调用意图”,再由外部代码负责具体执行。这个机制是智能体(Agent)开发的基石,也是 AI 编程中“模型选动作、代码做动作”这种协作模式的核心实现方式。
这篇文章以 LangGraph 为核心框架,围绕工具调用的完整链路展开:先说明模型、工具、状态图三者如何配合,再给出一个可运行的查询天气示例,接着解释 bind_tools、ToolNode、条件边等关键设计,最后整理常见报错和落地建议。如果你已经了解 LangChain 的基本用法,但还没有把 Agent 真正跑通,这篇文章可以帮你把工具调用这一环打通。它也可以作为高校“AI 编程与智能体开发”课程中 LangGraph 部分的学习笔记,重点内容放在可复现代码和排错方法上。
1. 工具调用是智能体从“会聊天”到“能办事”的关键一步
1.1 先理解模型输出与真实动作之间的断层
LLM 可以做翻译、总结、代码生成,但真实系统里往往需要“动作”:查库存、创建订单、发消息。模型不能直接执行这些动作,但它可以输出一个明确的调用请求。这个请求就是 Tool Call。例如用户说“帮我查一下厦门今天的天气”,模型可能不会直接给出天气预报,而是输出一个对get_weather工具的调用,参数是city="厦门"。真正的天气数据由外部函数执行后返回,模型再根据返回内容组织最终回答。
这里需要区分两个概念:函数调用和工具调用。在普通编程里,函数调用是代码主动调函数;在智能体开发里,工具调用通常指模型生成结构化调用参数,代码负责执行。模型没有“主动执行”能力,但被训练成在合适的场景输出tool_calls结构。LangGraph 的作用,是把这种结构转换成一次真实计算,再把计算结果送回模型上下文。
1.2 LangGraph 的核心思路:把工具调用当成图节点之间的消息流动
LangGraph 本身并不关心工具的内部实现,它关心的是执行顺序和数据状态。一个包含工具调用的 Agent,可以简化成两个节点:模型节点和工具节点。
模型节点接收历史消息后生成响应;如果响应里包含tool_calls,条件路由就会进入工具节点;工具节点按调用参数执行真实工具,并把结果包装成ToolMessage写回消息列表;随后又回到模型节点,让模型读取工具结果并生成最终回答。整个过程是一个循环,直到模型不再产生新的工具调用。
这种设计的价值在于:每一次工具调用都体现在状态中,任何一轮流程都可以回放。对调试和审计非常有利。相比直接在一个函数内部等待模型返回 JSON 再执行,LangGraph 把状态显式化,每一步是“谁调用谁、传了什么参数、返回了什么结果”都一清二楚。
1.3 LangGraph 与 LangChain 传统 Agent 的差异
LangChain 早期用AgentExecutor管理 ReAct 循环,Agent 内部隐藏了较多执行细节。LangGraph 则把流程显式建模成图,开发者能自己控制节点、边和共享状态。对于工具调用来说,LangGraph 的明显价值是:
- 可观测:中间每一步都是图中的一类消息。
- 可扩展:可以插入 human-in-the-loop、检查点、并行节点。
- 可控制:可以用条件边自定义如何决定继续调用工具还是结束。
| 维度 | 传统 LangChain Agent | LangGraph Agent |
|---|---|---|
| 流程建模 | 封装在 Executor 里 | 显式图结构 |
| 工具调用 | 按预置循环处理 | 由条件边和 ToolNode 控制 |
| 状态管理 | 消息列表,内部化 | 显式 State 在节点间传递 |
| 调试 | 需要看 Agent 日志 | 可查看每一步图状态 |
| 扩展 | 中间拦截较难 | 可插入中断、持久化、子图 |
在简单场景下使用AgentExecutor没有太大问题,但做复杂流程时,LangGraph 的控制力更合适。建议把工具调用理解成“图上的一次节点执行”,而不是“模型自动触发函数”,这正是 LangGraph 与其他 Agent 框架在使用体验上最大的不同。
2. 环境准备:版本确认、依赖安装和项目结构
2.1 先确认 Python、LangChain 和 LangGraph 的版本
工具调用 API 变化比较快。LangChain/LangGraph 的包结构在 2024 年到 2025 年做过多次调整。为了避免看文档时对不上,建议先确认当前环境中已安装的版本。
python --version pip show langgraph langchain-core langchain-openai如果没有安装,可以先创建虚拟环境,然后安装:
python -m venv .venv source .venv/bin/activate # Windows 使用 .venv\Scripts\activate pip install --upgrade langgraph langchain-openai python-dotenvlangchain-openai提供ChatOpenAI模型接口;langgraph提供StateGraph、ToolNode等核心类;python-dotenv用于读取.env文件中的 API Key。实际项目中,如果使用其他模型,还要安装对应的包,例如使用 Anthropic 就安装langchain-anthropic。
各依赖包的用途可以先用表格记住:
| 包名 | 作用 |
|---|---|
| langgraph | 状态图、节点、边、ToolNode |
| langchain-core | Message、tool 装饰器、接口定义 |
| langchain-openai | OpenAI 兼容模型接入 |
| python-dotenv | 读取 .env 环境变量 |
2.2 模型接口选择:能用 OpenAI,也能用兼容接口
不同大模型对工具调用支持的格式不同。OpenAI 的 gpt-4o 系列支持 function calling;很多开源或国产模型也提供兼容接口。在 LangGraph 中,只要模型类支持bind_tools,并且返回的标准消息中包含tool_calls,就可以接入同一个图结构。
如果你没有 OpenAI 的 Key,也可以使用兼容接口。比如配置base_url指向支持 OpenAI API 规范的服务:
from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="qwen-plus", api_key="your-api-key", base_url="https://your-endpoint.example.com/v1", temperature=0, )这里要提醒:base_url、模型名和工具调用支持度以实际服务为准。生产环境不要硬编码 Token,应通过环境变量或密钥管理服务注入。工具调用的稳定性非常依赖模型自身能力,同一个图结构切换模型后,需要重新测试工具描述是否被正确理解。
2.3 最小项目目录
agent_tool_demo/ ├── .env ├── requirements.txt ├── agent.py └── tools.pyrequirements.txt放依赖。tools.py放业务工具。agent.py放模型绑定、图构建和运行入口。.env放OPENAI_API_KEY等敏感配置,注意加入.gitignore。
这只是最小结构。如果项目变大,可以把图构建、工具注册、配置读取拆成独立模块,方便测试和复用。
3. 从零实现一个可运行的 LangGraph 工具调用示例
3.1 定义业务工具
先定义两个简单工具:查询天气和查询当前时间。@tool装饰器来自langchain_core.tools,它会把函数签名自动转成模型可见的 JSON Schema。
from datetime import datetime from langchain_core.tools import tool @tool def get_weather(city: str) -> str: """查询指定城市的天气信息。 Args: city: 城市名称,例如“厦门”。 Returns: 天气描述字符串。 """ weather_map = { "厦门": "厦门今天多云,26℃~32℃,东南风 3 级。", "上海": "上海今天小雨,24℃~29℃,东北风 4 级。", "北京": "北京今天晴天,18℃~31℃,西北风 2 级。", } return weather_map.get(city, f"暂时没有 {city} 的天气数据。") @tool def get_current_time() -> str: """获取服务器当前时间,返回格式化字符串。""" return datetime.now().strftime("%Y-%m-%d %H:%M:%S")关键点:@tool装饰器会把函数名作为工具名,Docstring 作为工具描述,类型注解作为参数 Schema。想让模型准确调用,描述里要说明参数含义、示例值、返回内容。不要只写“查询天气”,尽量写成“查询指定城市当天的天气情况,输入城市中文名,返回天气和温度”。模型对描述的理解直接决定了它会不会调用这个工具。
3.2 绑定工具到模型
需要先实例化模型,然后使用bind_tools(tools)把工具列表传给模型。之所以用bind而不是在 prompt 中手动拼 JSON,是因为 OpenAI 等模型原生支持tool_calls输出结构,识别准确率更高:
from langchain_openai import ChatOpenAI tools = [get_weather, get_current_time] llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) model_with_tools = llm.bind_tools(tools)调用模型时,如果用户问题需要工具,模型返回的AIMessage.tool_calls会包含调用请求;如果不需要,tool_calls为空列表。绑定发生在模型实例化之后、放入图之前,这样 agent 节点每次执行时,都会用已经绑定工具列表的模型生成响应。
3.3 构建状态图和 ToolNode
工具调用会写回消息列表,所以 State 里的 messages 要使用add_messagesreducer,保证新消息追加而不是覆盖:
from typing import Annotated, TypedDict from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from langgraph.prebuilt import ToolNode, tools_condition class State(TypedDict): messages: Annotated[list, add_messages] def agent_node(state: State): result = model_with_tools.invoke(state["messages"]) return {"messages": [result]}然后创建图:
graph_builder = StateGraph(State) graph_builder.add_node("agent", agent_node) tool_node = ToolNode(tools=tools) graph_builder.add_node("tools", tool_node) graph_builder.add_edge(START, "agent") graph_builder.add_conditional_edges( "agent", tools_condition, { "tools": "tools", END: END, } ) graph_builder.add_edge("tools", "agent") graph = graph_builder.compile()说明:
tools_condition是预置路由函数,会检查最后一条 AI 消息是否包含tool_calls。如果有,返回"tools";没有则返回END。ToolNode接收工具列表,自动从消息中解析调用并执行。add_edge("tools", "agent")表示工具执行完后,把ToolMessage返回给模型,让模型继续处理。- 最终如果模型不再调用工具,流程在 agent 节点结束。
这个流程已经是一个最小但完整的 ReAct 循环。模型可以连续调用多个工具,工具结果会依次写回消息列表。
3.4 完整运行脚本
把上面的内容合并成一个脚本,加上主函数:
def main(): user_input = input("请输入问题:例如“厦门今天天气怎么样?”\n> ") result = graph.invoke({"messages": [("user", user_input)]}) for msg in result["messages"]: if msg.type in ("ai", "tool"): print(f"[{msg.type}] {msg}") if __name__ == "__main__": main()运行方式:
export OPENAI_API_KEY="sk-..." python agent.py示例输入:
厦门今天天气怎么样?预期输出(不同模型可能有差异):
[ai] content='' tool_calls=[{'name': 'get_weather', 'args': {'city': '厦门'}, ...}] [tool] 厦门今天多云,26℃~32℃,东南风 3 级。 [ai] 厦门今天多云,26℃到32℃左右,东南风 3 级。这一步说明:模型没有自己编造天气,而是先请求工具,工具返回真实数据后再回复。
4. 深入理解 bind_tools、ToolNode 与条件边的配合
4.1 bind_tools 的常用参数与影响
以 OpenAI 风格模型为例,bind_tools可以传入多个参数:
| 参数 | 含义 | 常见值 | 注意事项 |
|---|---|---|---|
| tools | 工具列表 | [get_weather, get_current_time] | 每个工具必须有清晰的 name 和 description |
| tool_choice | 控制是否强制调用某个工具 | "auto"、"none"、工具名 | 强制工具名会降低灵活性,慎用 |
| parallel_tool_calls | 是否允许多个工具并行调用 | True / False | True 时模型可能一次调用多个工具 |
| strict | 是否启用严格 schema(部分模型) | False / True | 启用后参数必须严格匹配,但依赖模型支持 |
temperature不是bind_tools的参数,而是ChatOpenAI的初始化参数。工具调用场景建议设为 0 或较低值,减少输出随机性。tool_choice设为具体工具名时,表示模型只能调用指定工具,适合流程固定的场景,不适合开放问答。parallel_tool_calls在多工具场景很有用,但如果工具之间存在依赖,比如第二个工具需要第一个工具的结果生成参数,就要关闭并行,否则会在一次请求里拿到多个参数不完整的调用。
4.2 为什么用 reducer 管理消息
在线程编程中,多个节点可能共享同一个 State。add_messages是一个 reducer,它会按消息 ID 合并列表,把新消息追加进去。在 Agent 场景里,这一设计保证了工具执行结果不会覆盖模型历史。
如果你漏掉add_messages,多次节点更新会互相覆盖,最常见现象是模型节点返回后,工具节点传入时状态里只剩最后一条消息,导致后续模型看不到工具结果,甚至陷入死循环。调试时可以先打印result["messages"],检查是否包含HumanMessage、AIMessage、ToolMessage三类完整的消息链。
4.3 自定义条件路由
tools_condition适合最简单的判断,但真实项目里往往需要额外条件。例如,当模型调用了某类“只读工具”时,可以先去工具节点;当调用“写操作”时,需要先经过人工确认节点。这时可以自己写一个路由器:
def route_after_agent(state: State): last_message = state["messages"][-1] if not last_message.tool_calls: return END if last_message.tool_calls[0]["name"] == "create_order": return "human_confirm" return "tools"然后:
graph_builder.add_conditional_edges( "agent", route_after_agent, { "tools": "tools", "human_confirm": "human_confirm", END: END, } )这里展示的是组合能力:工具调用并不是只能进入ToolNode,它可以经过人工审批、权限检查、日志记录等多个节点。条件路由函数接收当前 State,返回下一个节点的标识,LangGraph 会按下标的映射继续执行。
4.4 ToolNode 内部做了什么
ToolNode做的事情可以理解为:
- 读取最近一条
AIMessage的tool_calls。 - 对每个调用找到同名工具。
- 用
args调用工具函数。 - 把返回值包装成
ToolMessage,并写入工具名、调用 ID。 - 返回
{"messages": [tool_message]}列表,状态图继续流转。
自己实现 ToolNode 时要注意:工具异常不能直接导致整个图崩溃,应把异常捕获后转成错误消息返回给模型。例如:
@tool def safe_http_request(url: str) -> str: """发起 HTTP GET 请求,返回状态码和摘要。""" try: import requests resp = requests.get(url, timeout=5) return f"HTTP {resp.status_code}, 内容长度 {len(resp.text)}" except Exception as e: return f"请求失败:{e}"在工具函数内部捕获异常,比试图在 ToolNode 外层拦截更稳。因为工具返回的是字符串,模型可以直接理解失败原因并调整参数重试。
5. 运行验证、调试与日志观察
5.1 从返回值确认工具调用链路完整
运行脚本后,检查result["messages"]。正常链路应该包含:
HumanMessage:用户输入。AIMessage:模型第一次响应,内容可能为空,但包含tool_calls。ToolMessage:工具执行结果,每条ToolMessage都有一个与AIMessage匹配的tool_call_id。AIMessage:模型读取工具结果后的最终回答。
如果缺少ToolMessage,说明没有走到工具节点。如果最终AIMessage后面还有tool_calls,说明流程可能没有结束,需要检查条件边和recursion_limit。
5.2 用 graph.stream 观察每一步执行
graph.invoke只返回最终结果,调试时更适合用graph.stream,它会逐步输出每个节点的执行结果:
for step in graph.stream( {"messages": [("user", "厦门今天天气怎么样?")]}, config={"recursion_limit": 10}, ): print(step)输出会展示哪一步是 agent 节点、哪一步是 tools 节点。例如:
{'agent': {'messages': [AIMessage(content='', tool_calls=[...])]}} {'tools': {'messages': [ToolMessage(content='厦门今天多云...', ...)]}} {'agent': {'messages': [AIMessage(content='厦门今天天气...')]}}这样可以直观看到模型确实先发起了工具调用,然后在工具结果返回后生成了最终回答。生产环境可以把这些 step 记录到日志系统,方便事后回放。
5.3 模型差异:不同模型工具调用格式不统一
不同模型对工具描述的字段要求不同。OpenAI 系列支持 name、description、parameters;部分开源模型可能要求严格 JSON 或缺少结构化输出。遇到“模型不调用工具”时,先确认模型原生支持 tool calling,再看是否需要在 prompt 中补充“你可以使用工具”。
某些情况下,模型支持工具调用,但因为工具描述不清,它选择用已有知识直接回答。例如用户问“今天天气”,如果工具描述里没有提到“今天、天气”,模型可能不会触发。建议在系统消息中写明:
system_prompt = "你是智能助手。当需要实时信息时,请使用相应工具。工具结果返回后,请基于工具结果回答。" messages = [("system", system_prompt), ("user", user_input)]然后把 messages 作为初始状态传入graph.invoke。要注意,不是所有模型都需要系统提示才会调用工具,但加上之后能明显提高触发率。
6. 常见问题排查:工具调用的典型故障与处理思路
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 模型始终不调用工具 | 工具描述不清晰、模型不支持、未绑定工具 | 检查bind_tools是否传入;打印 AIMessage.tool_calls | 优化工具描述;降低 temperature;确认工具列表 |
| 工具参数乱编 | 参数名与函数签名不一致、缺少类型注解 | 打印生成的 tool_calls 参数和工具 schema 对比 | 使用显式类型,在描述中给出示例值 |
| 调用后死循环 | 工具结果没形成 ToolMessage,或路由始终进入 tools | 打印每一步 state;查看 recursion_limit | 使用add_messages;配置tools_condition;设置recursion_limit |
| 多个工具同时调用时结果混乱 | 模型并行返回多个 tool_calls,工具之间有依赖 | 打印 ToolMessage 的 tool_call_id | 设置parallel_tool_calls=False;在工具内做依赖校验 |
| 工具执行异常导致图崩溃 | 工具函数抛出未捕获异常 | 查看完整异常堆栈 | 在工具内 try/except;返回可读错误消息 |
| 工具调用后答非所问 | 模型没有读取 ToolMessage 就直接结束 | 检查最终 AIMessage 是否基于工具结果 | 在模型输入中保留完整 messages 链,不要丢弃工具消息 |
6.1 推荐排查顺序
按以下顺序排查,能更快缩小问题范围:
- 输入是否正确:用户问题里是否包含工具函数的触发条件。
- 依赖版本:langgraph 和 langchain-core 版本是否匹配,是否安装正确。
- bind_tools:是否把工具列表交给模型。
- 模型输出:打印
response.tool_calls,确认模型是否产生调用。 - 路由:条件边是否能正确判断
tool_calls。 - 状态更新:messages 是否按链路追加。
- 完整报错:看堆栈开头,而不是只读最后一行。
6.2 日志与审计
如果在生产环境接入了 LangSmith 或自定义日志,可以记录每个tool_call的 id、name、args、result,形成审计链路。至少要在工具节点前后各打一条日志:
def agent_node(state: State): result = model_with_tools.invoke(state["messages"]) if result.tool_calls: for call in result.tool_calls: print(f"[tool_call] name={call['name']} args={call['args']}") return {"messages": [result]}打印工具名称和参数,能快速发现模型是否在乱传参数。注意不要把敏感信息打印到日志,例如查询条件中包含手机号时要做脱敏。
7. 工具调用的工程化最佳实践
7.1 工具设计要像写 API 一样严谨
工具函数暴露给模型,本质上是模型可见的 API。名称建议用动词开头,例如get_weather、send_email、create_order。描述建议包含:
- 这个工具什么时候用。
- 参数含义和格式。
- 返回值含义。
- 不适合用这个工具的场景。
参数尽量用基本类型:string、number、boolean。复杂对象会增加模型生成错误参数的概率。可选参数要标注默认值,不要把必填项描述成可选。工具返回内容不要太长,模型需要把它继续放进上下文,返回一万字会让后续生成变慢且费用变高。
7.2 权限、安全与敏感信息
工具返回值会被模型读取,再生成文本返回给用户。如果工具返回了敏感信息,模型可能把它直接输出。因此工具层要做:
- 字段级脱敏:不要把内部 ID、密钥、手机号完整返回给模型。
- 权限校验:在工具内部再次校验用户身份,不要假设只有模型能调用工具。
- 操作类工具要谨慎:写操作、删除操作尽量走人工确认或二次校验。
- 超时和限流:外部 API 调用要有超时时间,避免图节点长时间挂起。
@tool def query_user_order(order_id: str) -> str: """查询订单摘要,供客服使用。敏感字段已在返回前脱敏。""" order = db.get_order(order_id) if order is None: return "未找到订单。" return f"订单 {order_id} 状态是 {order.status},金额已脱敏。"这个例子里,工具不会返回用户真实手机号、支付账户等敏感字段,只给模型生成回答所需的摘要信息。
7.3 成本与延迟控制
工具调用会消耗更多 token:模型输出tool_calls,工具结果又作为新消息继续进入上下文。如果工具返回过长,例如大段数据库日志,会推高成本并拖慢后续回答。建议对工具返回值做长度限制:
def safe_result(result: str, max_len: int = 500): if len(result) <= max_len: return result return result[:max_len] + "...(已截断)"同时使用recursion_limit限制最大循环次数,并在图编译后用测试用例反复验证。常见配置可以是 10 或 20,超过限制说明流程可能异常结束,需要告警。
7.4 下一步扩展方向
跑通工具调用后,可以继续扩展以下方向:
- 人类确认节点:用
interrupt暂停图,交由人工批准后再继续。 - 持久化:使用 checkpointer 保存 Agent 会话状态,支持断点续跑。
- 子图:把复杂流程拆成多个图,在工具节点中调用子图。
- 多 Agent:不同 Agent 各管一组工具,通过消息协调。
- 流式输出:用
graph.stream或stream_mode把工具调用过程实时返回前端。
这些方向都建立在“工具调用是图上一个节点”这个基础上。先把这个最小链路跑通,再逐步扩展,会比一上来就搭建复杂多 Agent 系统更稳。
工具调用这一层跑通,后续无论做 RAG 查询、业务系统操作,还是多智能体协作,都有了一个可观测、可扩展的地基。实际项目里最容易出问题的不是代码语法,而是工具描述写得不清楚、状态更新被覆盖、异常没有回传给模型。只要这三条处理好,LangGraph 工具调用就能稳定地承担智能体的真实执行任务。建议从今天的最小示例开始,先把“模型产生调用、工具执行、结果回填、模型再回答”这条链路亲手跑一遍,再逐步加入权限、人工审批和持久化。