最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
从字符串约定到 LangChain:拆解 LLM 的 Tool Calling 流程
时间:2026-09-19 17:40:01 编辑:袖梨 来源:一聚教程网
当用户询问天气时,大语言模型本身并不掌握实时数据,需要先请求外部函数获取结果,再组织成自然语言回答。这个看似简单的过程涉及工具选择、参数解析、函数执行和结果回传。下面从手写字符串协议开始,逐步过渡到 LangChain 的结构化工具调用,并梳理实现中容易忽略的边界情况。
用户问“北京天气怎么样”,模型需要先获得天气数据,才能给出有依据的回答。Tool Call 就是把这个需求交给程序执行的一种机制。
在本文的本地函数示例中,模型负责选择工具和生成参数,Python 负责执行工具,再将结果交回模型。
用户:查一下 beijing 的天气
↓
模型请求:get_weather(city="beijing")
↓
Python 执行:得到 sunny,30度
↓
结果交回模型
↓
模型回答:北京晴,气温 30 度
这里的天气来自代码中写死的字典,只是演示数据。接入真实天气服务时,需要替换工具内部的查询逻辑。
项目中的三个文件,逐步展示了这套机制:
| 文件 | 请求怎样产生 | 程序怎样处理 |
|---|---|---|
01_toolcall.py | 手写字符串,模拟模型输出 | 按冒号拆分,再调用函数 |
02_prompt_protocol_model.py | 用提示词要求模型输出标签 | 正则提取工具名,JSON 解析参数,再调用函数 |
03_langchain_model.py | 向模型提供工具定义,接收结构化请求 | 读取 tool_calls、执行工具、回传 ToolMessage |
本文基于这些源文件讲解。代码修正以片段形式给出,原 Python 文件未修改,也未实际调用模型验证。
从第一个文件开始,先不用模型。
01_toolcall.py 定义了一个普通 Python 函数:
def get_weather(city: str) -> str:
weather = {
"beijing": "sunny",
"shanghai": "cloudy",
"fuzhou": "rainy",
}
return weather.get(city, "未知天气")
然后用一行字符串模拟模型输出:
model_output = "get_weather: fuzhou"
程序按约定拆出工具名和参数:
def parse_model_output(text: str) -> tuple[str, dict[str, str]]:
tool_name, city = text.split(":", maxsplit=1)
return tool_name.strip(), {"city": city.strip()}
tool_name, tool_args = parse_model_output(model_output)
if tool_name == "get_weather":
result = get_weather(**tool_args)
其中,get_weather(**{"city": "fuzhou"}) 相当于 get_weather(city="fuzhou")。
这段代码展示了工具调用的基本结构:解析请求 → 找到函数 → 传入参数 → 得到结果。它没有调用 LLM,也没有把结果交回模型。
这种字符串协议适合入门,但扩展起来很麻烦。没有冒号会解析失败,多个参数也需要重新设计格式。
第二个文件接入了真实模型,但工具调用格式仍由提示词约定。
02_prompt_protocol_model.py 要求模型在遇到天气问题时输出:
<Tool>get_weather</Tool>
<Args>{"city":"南昌"}</Args>
程序从普通文本中提取两个标签:
tool_match = re.search(r"<Tool>(.*?)</Tool>", text, re.DOTALL)
args_match = re.search(r"<Args>(.*?)</Args>", text, re.DOTALL)
(.*?) 捕获标签内的内容,re.DOTALL 让匹配可以跨行。提取到的参数仍然是字符串,需要再解析:
args = json.loads(args_match.group(1))
于是,模型生成的文本被转换为:
{"tool": "get_weather", "args": {"city": "南昌"}}
接下来仍由 Python 执行 get_weather(**call["args"])。当前示例到打印工具结果为止,没有再次调用模型生成自然语言回答。
这也解释了自定义协议的问题:模型可能漏写标签、输出无效 JSON,或者缺少 city。原代码遇到 JSON 解析失败时返回空字典,后续调用又可能因为缺少参数而报错。更合适的做法是明确报告格式错误,并在执行前检查参数:
args = call["args"]
if not isinstance(args, dict) or not isinstance(args.get("city"), str):
raise ValueError("工具参数必须包含字符串类型的 city")
readme.md 中的 <Tool>、<Args> 应理解为本例的自定义协议,不能据此推断所有模型内部都使用这套标签。
第三个文件使用模型接口支持的结构化工具调用,并通过 LangChain 统一处理。
03_langchain_model.py 给函数添加了 @tool:
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""查询演示天气,city 使用 beijing、fuzhou 或 nanchang。"""
weather = {
"beijing": "sunny,30度",
"fuzhou": "rainy,20度",
"nanchang": "cloudy,15度",
}
return weather.get(city, "未知天气")
默认情况下,函数名用作工具名,文档字符串说明用途,类型注解帮助生成参数定义。装饰器将函数包装成 LangChain 工具,供后续绑定和执行。LangChain 工具定义文档
然后将工具绑定到模型:
llm = load_llm().bind_tools([get_weather])
response = llm.invoke(messages)
bind_tools() 向模型提供工具定义。调用后,可以从 response.tool_calls 读取结构化请求;执行仍由应用代码负责。底层模型和服务接口需要支持工具调用。LangChain 工具调用文档
返回值可能类似下面这样,具体 ID 由接口生成:
[
{
"name": "get_weather",
"args": {"city": "beijing"},
"id": "call_example",
"type": "tool_call",
}
]
这里有三个需要使用的字段:
| 字段 | 用途 |
|---|---|
name | 找到要执行的工具 |
args | 提供已经解析好的参数 |
id | 将执行结果与原调用关联 |
因此,第三种方式省去了手写标签和正则解析。它仍然需要程序检查参数、处理执行错误,不能保证模型一定选对工具或填对城市。
执行工具后,还差一次结果回传。
原代码在 for tool_call in response.tool_calls 循环外创建 ToolMessage,会出现两个问题:
- 没有工具调用时,
result和tool_call尚未赋值,却被使用。 - 一次请求多个工具时,只回传最后一个结果,前面的调用缺少对应消息。
每个工具调用都应该得到自己的结果消息。 ToolMessage 的 tool_call_id 必须对应原请求的 id,模型才能关联请求与结果。LangChain 结果回传说明
可以保留第三个文件中的工具定义、load_llm() 和入口代码,用下面的函数替换 main():
def main() -> None:
user_prompt = " ".join(sys.argv[1:]).strip() or DEFAULT_USER_PROMPT
tools = {get_weather.name: get_weather}
llm = load_llm().bind_tools(list(tools.values()))
messages = [
SystemMessage(content=SYSTEM_PROMPT),
HumanMessage(content=user_prompt),
]
for _ in range(5):
response = llm.invoke(messages)
messages.append(response)
if response.invalid_tool_calls:
print("工具调用参数解析失败,请检查模型输出。")
return
if not response.tool_calls:
print(response.content)
return
for call in response.tool_calls:
selected_tool = tools.get(call["name"])
try:
if selected_tool is None:
raise ValueError(f"未知工具:{call['name']}")
result = selected_tool.invoke(call["args"])
except Exception as exc:
result = f"工具执行失败:{exc}"
messages.append(
ToolMessage(
content=str(result),
name=call["name"],
tool_call_id=call["id"],
)
)
print("已达到 5 轮调用上限,任务尚未完成。")
这个循环处理了三种情况:没有工具请求就输出回答;有请求就逐个执行、逐个回填;模型需要继续调用工具时进入下一轮。5 轮是教学示例的预算,达到上限会明确停止。模型接口本身的网络异常仍需另行处理。
对一次天气查询,常见的消息顺序是:
HumanMessage:查询北京天气
AIMessage:请求 get_weather,参数为 beijing
ToolMessage:返回 sunny,30度,关联该次调用 ID
AIMessage:根据工具结果回答用户
修正后的代码还通过工具名查表分发,避免将所有请求都直接交给 get_weather。以后添加其他工具时,可以沿用这套分发方式。
运行前,在项目目录安装所需依赖:
cd D:workspaceysh_aibackendpythontoolcall
python -m venv .venv
..venvScriptspython.exe -m pip install langchain-openai langchain-core python-dotenv
第二、第三个文件都从脚本所在目录加载 .env,对应路径是 src/.env:
API_KEY=你的密钥
BASE_URL=服务接口地址
MODEL=该服务支持的模型名称
这里的变量名是项目约定。第三个示例所选模型必须支持工具调用,具体可用模型以服务提供方为准。
原代码还有几处值得在运行前处理:
- 第二个文件会打印完整 API Key,删除该调试输出。
- 第二个文件的 f-string 内外复用了双引号。为兼容 Python 3.10、3.11,将内层改为单引号:
return f"{city} 的天气是: {weather.get(city, '未知天气')}"。 - 第二个文件只打印了“模型原始输出”的标题,没有打印正文。取得
model_output后补上print(model_output),才能观察标签或普通文本回答。 - 第三个文件用
"".join(sys.argv[1:])拼接参数,会丢失单词间空格。上面的修正版使用" ".join(...)。
然后按顺序运行:
..venvScriptspython.exe src 1_toolcall.py
..venvScriptspython.exe src 2_prompt_protocol_model.py "帮我查一下南昌的天气"
..venvScriptspython.exe src 3_langchain_model.py "帮我查一下 beijing 的天气"
三个文件的城市键并不完全一致。例如第三个示例使用 nanchang,传入“南昌”会查不到。可以统一参数约定,或在工具内部增加城市别名转换。
验证第三个示例时,分别输入“你好”“查询 beijing 天气”“查询 beijing 和 fuzhou 天气”,观察零次、一次和可能的多次工具请求。重点看 response.tool_calls 与结果消息是否逐一对应;模型是否在同一轮请求两个城市,取决于实际响应。
当这套流程跑通后,把字典查询换成天气 API、数据库查询或文档检索,工具调用的主流程仍然相同:接收模型请求,执行明确的操作,把结果交回模型。