版本:langchain==1.3.18,langchain-core、langchain-openai 使用配套最新版本;通过 OpenAI 兼容接口对接 DeepSeek-v4-flash。
本文基于真实调试案例,从现象、代码、底层协议、高频坑点完整梳理,面向刚接触 LangChain Tool-Call 的开发者。
前言
Agent 智能体标志性落地能力是 Tool-Call(工具调用):
- 大模型的职责:输出结构化工具调用 JSON 指令;
- 应用层职责:解析指令、在本地执行 Python 工具函数,把执行结果封装成协议消息回传给大模型,再由大模型做后续推理生成回答。
⚠️核心认知:
大模型永远不会执行你的本地 Python 函数,它只输出调用指令;是否输出工具指令,是模型按照工具 Schema、上下文提示、tool_choice 参数做输出侧的生成决策,不是"远程调用执行"。
bind_tools
的作用:把工具函数转换成 JSON Schema,随请求传给 LLM,告诉模型"你可以输出这些工具的调用格式",
它不会控制模型一定输出工具调用,只是给模型提供可选输出模板
。
很多新手重大误区:被 @tool 装饰器迷惑,以为装饰完,大模型就可以远程跑本地函数。完全不是,全部工具执行逻辑都要业务代码自己处理。
下面以「获取当前系统时间」作为 Demo,完整复现从幻觉输出、工具指令、消息协议到 400 报错,直到业务跑通的全过程。
依赖安装
python
pip install langchain==1.3.18 langchain-openai python-dotenv
.env 配置文件
python
DEEPSEEK_API_KEY=sk-xxx
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
完整 Demo:从幻觉输出到业务跑通
python
import datetime
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
import os
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage
from langchain_core.tools import tool
load_dotenv(override=True)
model = init_chat_model(
model="deepseek-v4-flash",
model_provider="openai",
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url=os.getenv("DEEPSEEK_BASE_URL")
)
message = HumanMessage(content="查询当前时间")
# 1、原始提问,不带工具Schema下发
resp = model.invoke([message])
print(f"resp1:{resp.content}")
@tool
def get_now():
"""获取当前系统时间"""
return f"当前时间:{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
# 2、bind_tools:下发工具Schema,无SystemMessage约束
# bind_tools:仅追加tools schema到请求体,不强制模型输出
model_with_tools = model.bind_tools([get_now])
resp = model_with_tools.invoke([message])
print(f"resp2:{resp.content},tools:{resp.tool_calls}")
# 3、增加SystemMessage作为行为提示约束
SYS_MSG = SystemMessage(
content="你拥有获取当前时间工具,user查询当前时间或日期,优先输出get_now的工具调用指令,禁止使用内部知识库编造时间。非时间问题正常作答即可")
resp = model_with_tools.invoke([SYS_MSG, message])
print(f"resp3:{resp.content},tools:{resp.tool_calls}")
# 4、业务代码解析,本地执行工具,封装ToolMessage回传模型
my_tools = {"get_now": get_now}
if resp.tool_calls:
for tc in resp.tool_calls:
bingo_tool = my_tools[tc["name"]]
tool_result = bingo_tool.invoke(tc["args"])
# ToolMessage 的 tool_call_id 必须与上游 AIMessage.tool_calls 里的 id 一一对应
tools_msg = ToolMessage(content=tool_result, tool_call_id=tc["id"])
# 消息顺序必须严格:System -> Human -> AIMessage(带 tool_calls) -> ToolMessage
resp = model_with_tools.invoke([SYS_MSG, message, resp, tools_msg])
print(f"resp4:{resp.content},tools:{resp.tool_calls}")
程序输出结果
python
resp1:根据系统信息,当前日期是 **2026年5月9日**。
具体时刻(时:分)建议您直接查看设备上的时钟。
resp2:,tools:[{'name': 'get_now', 'args': {}, 'id': 'call_00_bbxyVyybiajx2zp4V3Xy1805', 'type': 'tool_call'}]
resp3:,tools:[{'name': 'get_now', 'args': {}, 'id': 'call_00_MysgjGzALwnpzAUNYSGy5761', 'type': 'tool_call'}]
resp4:当前时间是 **2026年9月1日 09:11:08**。,tools:[]
逐段现象解析
resp1:不带工具Schema直接提问,模型输出幻觉时间
输出:2026年5月9日,来自模型训练截止的静态知识库。
大模型本身没有访问本机/服务器实时系统时间的能力,时间类查询极易产生幻觉;没有工具Schema的前提下,只能输出知识库脑补答案。
resp2:bind_tools 绑定工具,无 SystemMessage
content 为空字符串,tool_calls 数组有值
这是 OpenAI 兼容协议标准行为:
当模型选择输出工具调用指令,AIMessage 的 content 字段为空,填充 tool_calls 数组,告知业务层:应当调用哪个工具、入参、call_id。
⚠️重点:这里仅仅是模型输出的结构化指令,
get_now() 完全没有被执行;执行动作100%交给业务层Python代码
。
resp3:增加 SystemMessage 系统提示
SystemMessage 属于软性行为提示,给到模型作为输出参考,多数场景下会引导模型输出 tool_calls,减少编造时间。
关键:bind_tools() 只是把工具Schema塞进HTTP请求体,不会强制模型输出工具调用指令。SystemMessage 只是软引导,不是强约束;即便给了提示,部分模型仍然会绕过工具,直接输出编造答案。生产环境不能只靠提示词兜底。
resp4:业务层执行本地工具 + ToolMessage 回传,拿到最终回答
完整 Tool-Call 链路:
- 业务代码解析 AIMessage 的 tool_calls;
- 本地 Python 执行对应工具函数,拿到真实结果;
- 构造 ToolMessage,tool_call_id 必须和上游 AIMessage.tool_calls 里面的 id 一一对应;
- 组装完整消息列表:SYS_MSG, HumanMessage, AIMessage(携带 tool_calls), ToolMessage,再次调用LLM;
- LLM结合工具返回的原始数据,整理成自然语言输出。
⚠️协议硬性约束:
ToolMessage
(对应协议 role:tool)
必须紧跟一条携带 tool_calls 的 AIMessage
。如果消息顺序错乱、缺少前置的 AIMessage,OpenAI兼容接口直接返回400:
Messages with role 'tool' must be a response to a preceding message with 'tool_calls'
新手高频踩坑清单(LangChain1.3.18)
坑1:invoke 参数类型混淆
❌错误:直接传入单个消息对象
python
resp = model.invoke(HumanMessage(content="查询当前时间"))
# ValueError: Invalid input type
✅正确:消息对象必须放到列表;字符串可以裸传
python
resp = model.invoke([HumanMessage(content="查询当前时间")])
resp = model.invoke("查询当前时间")
invoke() 入参两种合法形式:原始字符串;listBaseMessage。SystemMessage/HumanMessage/AIMessage/ToolMessage 都属于BaseMessage子类,必须包装进列表。
坑2:SystemMessage 写在模型初始化参数
❌错误
python
model = init_chat_model(
...
system="xxx" # init_chat_model 没有system初始化参数!
)
✅正确:SystemMessage 属于会话消息,每次 invoke 随消息列表传入;模型实例是无状态,不会保存system提示。
坑3:篡改 HumanMessage.content 存放工具返回结果
不要图省事,把工具返回字符串追加到用户 HumanMessage 的 content。
❌错误:message.content += tool_result
OpenAI兼容接口识别不到这是工具执行结果,只会当成普通用户文本,工具会话协议完全失效。工具输出必须使用独立 ToolMessage 对象承载。
坑4:消息列表顺序错乱,触发400报错
报错:Messages with role 'tool' must be a response to a preceding message with 'tool_calls'
根因:消息数组缺失上一轮携带 tool_calls 的 AIMessage 对象。
✅消息固定顺序:
SystemMessage, HumanMessage, AIMessage(带 tool_calls), ToolMessage
写 LangGraph 的时候修改 state"messages",同样要严格遵守该顺序,否则下游OpenAI兼容接口直接400。
坑5:无脑全量走 Tool-Call 二次LLM请求
例如获取时间这类场景:工具返回本身就是可读最终答案,不需要再丢给大模型复述一遍。
工程上可以做分支判断,减少一次不必要LLM请求:
python
if resp.tool_calls:
tc = resp.tool_calls[0]
if tc["name"] == "get_now":
# 工具返回即可直接对外输出,跳过第二轮LLM请求
res = my_tools[tc["name"]].invoke(tc["args"])
print(res)
else:
# 查询数据库、查股价,原始数据需要模型做摘要改写,才走ToolMessage回传完整链路
...
坑6:对 tool_choice="required" 的错误理解
❌错误示范:在if-else内反复调用 bind_tools 传入 tool_choice="required"。
bind_tools
返回全新模型实例,每次bind都会把全部tools schema重新打包进请求;
tool_choice="required"
只强制输出任意一个工具调用,不能指定具体某一个工具
。
三个tool_choice枚举语义:
- auto:模型自主选择输出工具调用或者直接回答(默认)
- required:必须输出至少一个工具调用指令,工具任意选,不能直接输出文本回答
- tool_choice="get_now":直接传工具名字符串,强制指定调用某一个确切工具(LangChain 底层转成 OpenAI 协议的 {"type":"function","function":{"name":"get_now"}}),想要强制调用get_now应当使用该形式
⚠️不要全局无条件开启 tool_choice="required",闲聊场景会被强制输出工具调用,业务异常。
工程正确方案:
- SystemMessage 做软性提示引导;
- 启动时预创建两个绑定实例:普通对话用默认 auto;意图识别命中时间类查询时,切换到 bind_tools(tool_list, tool_choice="get_now") 的强制实例,精确锁定该工具。请求路径上不做任何 bind。
坑7:不要幻想 SystemPrompt 100%触发工具
即便下发完整Schema + System提示,部分场景模型依然会绕过工具直接幻觉输出。不能把业务可靠性完全寄托提示词,上层意图识别 + 精确指定tool_choice作为兜底。
Tool-Call 标准完整流转图

补充:AgentExecutor、LangGraph 本质就是封装这套消息循环,底层消息协议完全不变。
用 create_agent 直接交互:框架自动跑消息循环
前面手动解析 tool_calls、本地执行、封装 ToolMessage 回传的全套循环,LangChain 1.x 已经封装成预构建 agent:create_agent(基于 LangGraph 图实现,从 langchain.agents 导入)。直接把模型实例和工具列表交给它:
python
from langchain.agents import create_agent
my_agent = create_agent(model=model, tools=[get_now])
resp = my_agent.invoke({"messages":[message]})
print("===== 消息流转拆解 =====")
for idx, msg in enumerate(resp["messages"]):
t = type(msg).__name__
print(f"\n[{idx}] {t}")
if hasattr(msg, "tool_calls") and msg.tool_calls:
print(f"👉触发工具调用: {msg.tool_calls[0]['name']}")
if t == "ToolMessage":
print(f"🔧工具返回: {msg.content}")
if hasattr(msg, "content") and msg.content:
print(f"📝内容: {msg.content}")
# 拿最终输出
ans = resp["messages"][-1].content
print(f"\n🎯最终回答:{ans}")
agent 内部执行的正是前面流转图的六步,返回的 resp"messages" 就是完整消息链路,与手写循环产出的消息序列完全一致:
- HumanMessage:用户提问「查询当前时间」;
- AIMessage:content 为空字符串,tool_calls 数组携带 get_now 调用指令,对应前面 resp2、resp3 的输出;
- ToolMessage:框架自动执行 get_now(),content 是真实系统时间,tool_call_id 与上游 AIMessage 一一对应;
- AIMessage:结合工具结果生成最终自然语言回答,直接取 resp"messages"-1.content。
有了 agent,业务代码不再手写消息循环;但理解底层协议依然必要:一旦涉及自定义节点编排、工具执行报错处理,绕不开 state"messages" 里的消息对象操作。
总结
- LangChain 只负责封装工具Schema、消息对象;工具执行全部由业务层接管,大模型永远不会运行本地Python函数,只会输出工具调用结构化指令;
- invoke() 入参区分字符串 / listBaseMessage,所有消息实例必须放到列表传入;
- ToolMessage 必须携带合法 tool_call_id,消息顺序严格遵守协议,否则OpenAI兼容接口返回400;
- bind_tools 只是下发工具Schema,不等于强制调用工具;
tool_choice="required":强制输出任意一个工具调用,不能指定具体工具;
tool_choice="get_now":精确强制调用指定工具;
SystemMessage属于软提示,存在概率失效,生产要结合上层意图识别做兜底;
-
如果工具返回原始结果已经可以直接对外输出,不需要无脑回传给LLM复述,减少不必要API开销;
-
后续迁移 LangGraph 智能体开发,graph节点流转本质就是这套消息对象循环处理,协议约束完全一致。