大模型Tools(Function Calling)实用性分析 - 以媒体信息解析为例

一、背景

作为一个喜欢收集影视资源的人,笔者面临的一个任务是:从文件名中解析出格式化的媒体信息(信息源统一为The Movie Database (TMDB)),方便整理文件,如:

文件名 格式化信息
Young.Woman.and.the.Sea.2024.2160p.DSNP.WEB-DL.H265.HDR.DDP5.1.Atmos-ADWeb.mkv {'title': '泳者之心', 'genre': 'movie', 'year': 2024}
【動畫瘋】物語系列 第外季&第怪季[9][1080P].mp4 {'title': '物语系列', 'genre': 'tv(anime)', 'year': 2009, 'tv_season_num': 5, 'tv_episode': 9}

那么,在给定文件名的情况下,怎么用大语言模型(LLM,以下简称大模型) + TMDB API来完成媒体信息的解析工作呢?这篇文章应运而生。

当然,解析媒体信息 + 整理媒体文件(或者说媒体文件刮削),已经有很多现成的解决方案,如nas-toolsjellyfin,笔者更多是想探索大模型的可能性。

1
2
3
"太长不看"的总结:
- qwen-plus-latest (qwen 2.5) 强于 deepseek-chat (v2.5),gpt-4o-mini/gemini-1.5-flash落后
- Tools(Function Calling)在机器间交互不好用,笔者的JSON调用模式效率&效果更优。

二、方案选择

文件名媒体信息,大致分为这几步:

  1. 从文件名中解析关键字,去tmdb搜索,获得标题、分类信息;

  2. 如果是电视剧且标题里没有季度编号,则需要去tmdb查询季度信息,确定这是第几季;

  3. 如果是电视剧,还需要从标题里解析这是该季度的第几集。

按这个思路,大概有3种解决方案:

1. 代码控制流程

调用tmdb api等步骤由代码控制,大模型只负责

  • 从文件名中解析出标题、季度信息、集数信息;
  • 当tmdb搜出多个结果时,选择最匹配的一个;
  • 当tmdb查询到季度信息后,选择最匹配的一个。

代码开发量较大,没有用到大模型的规划能力。不考虑该方案。

2. 定义函数+Tools调用

我们可以定义一些函数:搜索tmdb的函数、查询tv季度信息的函数,传入文件名后让大模型判断需要调用哪些函数,最终得到媒体信息。

OpenAI在2023年6月发布了Function Calling,随后各家大模型厂商开始跟进。目前该功能逐渐改名为Tools。代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
tools = [
{
"type": "function",
"function": {
"name": "get_delivery_date",
"description": "Get the delivery date for a customer's order. Call this whenever you need to know the delivery date, for example when a customer asks 'Where is my package'",
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The customer's order ID.",
},
},
"required": ["order_id"],
"additionalProperties": False,
},
}
}
]

messages = [
{"role": "system", "content": "You are a helpful customer support assistant. Use the supplied tools to assist the user."},
{"role": "user", "content": "Hi, can you tell me the delivery date for my order?"}
]

response = openai.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)

3. 定义函数+JSON调用

Tools的核心逻辑是将函数声明/函数调用放到一个单独的字段来传递。优点是在人机对话时能隐藏函数调用的细节;缺点是对模型能力(更直接地说是训练/微调的数据集)有更高要求。笔者主要是API场景,更喜欢将函数声明/函数调用放到message记录内,对模型兼容性更好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
functions = {
"tmdb_search": {
"keyword": "string, tmdb search keyword, not include year",
},
"tv_season_info": {
"tmdb_id": "int, tmdb tv id",
},
"done": {
"title": "string, media title",
"genre": "string, media genre",
"year": "int, media year",
"tv_season_num": "int, tv season number",
"tv_episode": "int, tv episode number",
},
}
payload = {
"model": llm_model,
"messages": [
{
"role": "system",
"content": '你是一个函数调用器,擅长基于上下文,返回JSON格式的调用信息:`{"function":"xxx","arguments":{"key": "value"}}`。'
+ "我会提供一个文件名,你的目标是获取媒体文件的title, genre, year, tv_season_num, tv_episode,并调用done函数。你可以调用tmdb_search和tv_season_info函数来获取tmdb信息。",
},
{
"role": "system",
"content": json.dumps(functions, ensure_ascii=False),
},
{"role": "user", "content": filename},
],
}

三、代码

1. 定义函数

首先定义两个函数:搜索tmdb、查询季度信息。函数都很简单,调用tmdb API + 将API返回值转成想要的格式。

注意点:由于tmdb的search api,关键字参数不支持传入年份,所以在将函数定义传给大模型时需要强调这一点(见下文)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
tmdb_api_key = os.environ["tmdb_api_key"]
tmdb_hds = {
"Authorization": "Bearer " + tmdb_api_key,
}
session = requests_cache.CachedSession()

def tmdb_search(keyword: str) -> str:
url = "https://api.themoviedb.org/3/search/multi"
params = {
"query": keyword,
"include_adult": True,
"language": "zh",
}
resp = session.get(url, headers=tmdb_hds, params=params, timeout=10)
resp.raise_for_status()
jr = resp.json()
if not jr["results"]:
return "not found"

choices = []
for res in jr["results"]:
info = []
release_year = res.get("release_date", res.get("first_air_date"))[:4]
if not release_year:
continue
media_type = res["media_type"]
orig_lang = res.get("original_language", "")
anime_genre = 16
if media_type == "tv" and orig_lang == "ja" and anime_genre in res["genre_ids"]:
media_type = "tv(anime)"
info.append(f'name: {res.get("name", res.get("title"))}')
info.append(f"year: {release_year}")
info.append(f"genre: {media_type}")
info.append(f"id: {res['id']}")
info.append(f"popularity: {res['popularity']}")
choices.append("\n".join(info))
choices_text = "\n\n".join(choices)
return choices_text

def tv_season_info(tmdb_id: str) -> str:
url = f"https://api.themoviedb.org/3/tv/{tmdb_id}?language=zh"
resp = session.get(url, headers=tmdb_hds, timeout=10)
resp.raise_for_status()
jr = resp.json()
seasons = resp.json()["seasons"]
choices = [f"title: {jr['name']}"]
for s in seasons:
info = []
info.append(f"tv_season_num: {s['season_number']}")
info.append(f"name: {s['name']}")
choices.append(", ".join(info))
return "\n".join(choices)

2. Tools调用

Tools算是一个标准格式,笔者参考Deepseek的文档(Function Calling | DeepSeek API Docs)编写了如下代码。考虑到用JSON格式返回会降低大模型的推理能力,所以笔者这里先用自然语言和大模型交互,有结果后再调用一次大模型将媒体信息提取为JSON,用消耗更多token换取准确率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
def process_tools(filename: str):
token_usage = defaultdict(int)
payload = {
"model": llm_model,
"messages": [
{
"role": "system",
"content": "你是一个敏锐的媒体文件整理员,能通过查询TMDB来获取媒体文件的详细信息,包括分类、标题、年份、tv季度、tv集数。",
},
{"role": "user", "content": filename},
],
"tools": [
{
"type": "function",
"function": {
"name": "tmdb_search",
"description": "search media info from TMDB",
"parameters": {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "search keyword, not include year",
}
},
"required": ["keyword"],
},
},
},
{
"type": "function",
"function": {
"name": "tv_season_info",
"description": "get tv season info from TMDB",
"parameters": {
"type": "object",
"properties": {
"tmdb_id": {
"type": "string",
"description": "tmdb tv id",
},
},
"required": ["tmdb_id"],
},
},
},
],
}
results: t.List[str] = [filename]
# results: t.List[str] = []
while True:
resp = session.post(llm_url, json=payload, timeout=30)
resp.raise_for_status()
jr = resp.json()
message = jr["choices"][0]["message"]
for k, v in jr["usage"].items():
token_usage[k] += v
print("message", message)
if not message["tool_calls"]:
results.append(message["content"])
break
payload["messages"].append(message)
for tool_call in message["tool_calls"]:
info = tool_call["function"]
args = info["arguments"]
if isinstance(args, str):
args = json.loads(args)
if info["name"] == "tmdb_search":
res = tmdb_search(**args)
elif info["name"] == "tv_season_info":
res = tv_season_info(**args)
else:
raise ValueError(f"unknown function: {info['name']}")
print("tool call", info["name"], res)
results.append(res)
payload["messages"].append(
{"role": "tool", "tool_call_id": tool_call["id"], "content": res}
)

payload = {
"model": llm_model,
"messages": [
{
"role": "system",
"content": "你是一个文本解析器,擅长从文本中提取关键信息,并用JSON格式输出。你需要提取的信息为:title(str), genre(str), year(int), tv_season_num(int), tv_episode(int)",
},
{"role": "system", "content": json.dumps(results, ensure_ascii=False)},
],
}
resp = session.post(llm_url, json=payload, timeout=30)
resp.raise_for_status()
jr = resp.json()
resp_text: str = jr["choices"][0]["message"]["content"]
for k, v in jr["usage"].items():
token_usage[k] += v

resp_text = resp_text[resp_text.find("{") :]
resp_text = resp_text[: resp_text.rfind("}") + 1]
print(resp_text)
print("usage", token_usage)

3. JSON调用

JSON调用的版本相比Tools调用的版本更简洁,主要是:

  1. 传给大模型的函数定义,从结构上来说更简洁
  2. 大模型直接返回JSON格式的函数调用,解析起来更简单
  3. 大模型始终输出JSON,不需要在结束后再解析一次。

关键点:函数的返回值在传给大模型之前,最好加上前缀强调来提升准确率:res = f"调用{jr['function']}的返回值:\n{res}"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def get_clean_json(text: str) -> str:
text = text[text.find("{") :]
text = text[: text.rfind("}") + 1]
return text


def process_fc(filename: str):
usage = defaultdict(int)
functions = {
"tmdb_search": {
"keyword": "string, tmdb search keyword, not include year",
},
"tv_season_info": {
"tmdb_id": "int, tmdb tv id",
},
"done": {
"title": "string, media title",
"genre": "string, media genre",
"year": "int, media year",
"tv_season_num": "int, tv season number",
"tv_episode": "int, tv episode number",
},
}
payload = {
"model": llm_model,
"messages": [
{
"role": "system",
"content": '你是一个函数调用器,擅长基于上下文,返回JSON格式的调用信息:`{"function":"xxx","arguments":{"key": "value"}}`。'
+ "我会提供一个文件名,你的目标是获取媒体文件的title, genre, year, tv_season_num, tv_episode,并调用done函数。你可以调用tmdb_search和tv_season_info函数来获取tmdb信息。",
},
{
"role": "system",
"content": json.dumps(functions, ensure_ascii=False),
},
{"role": "user", "content": filename},
],
}
for _ in range(5):
resp = session.post(llm_url, json=payload, timeout=30)
resp.raise_for_status()
jr = resp.json()
message = jr["choices"][0]["message"]
for k, v in jr["usage"].items():
usage[k] += v
print("message", message)
jr = json.loads(get_clean_json(message["content"]))
if jr["function"] == "tmdb_search":
res = tmdb_search(jr["arguments"]["keyword"])
elif jr["function"] == "tv_season_info":
res = tv_season_info(jr["arguments"]["tmdb_id"])
elif jr["function"] == "done":
break
else:
raise ValueError(f"unknown function: {jr['function']}")
print("function call", jr["function"], res)
res = f"调用{jr['function']}的返回值:\n{res}"
payload["messages"].append({"role": "user", "content": res})
else:
print('failed to get "done" function call')
jr = {}

if jr:
print(jr["arguments"])
print("usage", usage)

四、测试结果

测试目标是让大模型输出格式化的媒体信息:

文件名 格式化信息
Young.Woman.and.the.Sea.2024.2160p.DSNP.WEB-DL.H265.HDR.DDP5.1.Atmos-ADWeb.mkv {'title': '泳者之心', 'genre': 'movie', 'year': 2024}
【動畫瘋】物語系列 第外季&第怪季[9][1080P].mp4 {'title': '物语系列', 'genre': 'tv(anime)', 'year': 2009, 'tv_season_num': 5, 'tv_episode': 9}

笔者选用了四个低价的大模型来测试,最贵是的gpt-4o-mini,输出$0.6/1M tokens:

  • deepseek-chat的api (v2.5 09/05更新),低价+开源
  • qwen-plus-latest的api (09/19更新),低价+基于开源的qwen 2.5 72B
  • gpt-4o-mini的api (07/18更新),低价+国际大厂
  • gemini-1.5-flash (04/13更新),低价+国际大厂
文件名 方案 模型 token消耗 结果
Young.Woman.and.the.Sea.2024.2160p.DSNP.WEB-DL.H265.HDR.DDP5.1.Atmos-ADWeb.mkv Tools调用 deepseek-chat 1141 成功
同上 Tools调用 qwen-plus-latest 1481 部分成功,标题解析成了泳者之心 (Young Woman and the Sea)
同上 Tools调用 gpt-4o-mini 747 成功
同上 Tools调用 gemini-1.5-flash 610 部分成功,标题解析成了Young.Woman.and.the.Sea
【動畫瘋】物語系列 第外季&第怪季[9][1080P].mp4 Tools调用 deepseek-chat 2821 成功,但不稳定(超时/第二轮时无法触发tools调用等)
同上 Tools调用 qwen-plus-latest 1856 成功
同上 Tools调用 gpt-4o-mini 1667 部分成功,未解析出第几季/第几集
同上 Tools调用 gemini-1.5-flash 1265 失败,tmdb api调用成功但结果解析很糟糕
Young.Woman.and.the.Sea.2024.2160p.DSNP.WEB-DL.H265.HDR.DDP5.1.Atmos-ADWeb.mkv JSON调用 deepseek-chat 652 成功
同上 JSON调用 qwen-plus-latest 575 成功
同上 JSON调用 gpt-4o-mini 559 成功
同上 JSON调用 gemini-1.5-flash 587 成功
【動畫瘋】物語系列 第外季&第怪季[9][1080P].mp4 JSON调用 deepseek-chat 1158 成功
同上 JSON调用 qwen-plus-latest 942 成功
同上 JSON调用 gpt-4o-mini 540 部分成功,未解析出第几季/第几集
同上 JSON调用 gemini-1.5-flash 262 失败,直接没有调用tmdb api

五、结论

从大模型对比的角度来看:

  • qwen-plus-latest表现最好。价格比deepseek-chat稍低,但速度更快、效果更好。充分体现了最新开源的qwen 2.5家族的实力。

  • deepseek-chat在Tools调用场景发挥不稳定,比如解析【動畫瘋】物語系列 第外季&第怪季[9][1080P].mp4时,虽然知道要调用函数获取季度信息,但却没有按Tools格式输出,导致后续无法衔接:

    1
    {'content': '从搜索结果来看,有两个不同的条目:一个是“物語系列”(2009年,TV动画),另一个是“春风物语”(2023年, 电影)。我们需要的是“物語系列”的信息。\n\n接下来,我们需要获取“物語系列”的详细信息,特别是“第外季”和“第怪季”的相关信息。由于这些季数可能不是标准的季数,我们需要进一步查询以确认。\n\n请调用 `tv_season_info` 函数,传入 `tmdb_id` 为 `46195`, 以获取“物語系列”的季数信息。', 'role': 'assistant', 'tool_calls': None, 'function_call': None}
  • gpt-4o-mini表现一般,价格最贵,但容易忽略TV作品的第几季/第几集。

  • gemini-1.5-flash价格最低,表现也最差。

从Tools使用的角度来看:

  • Tools诞生的场景是:在聊天机器人对话过程中,兼顾用户体验和外部函数调用。但在API场景中,“兼顾用户体验”的部分成了累赘。
  • 笔者设计的JSON调用模式,对机器间交互设计,在外部函数调用方面比Tools方式效率更高、准确度更高。

大模型Tools(Function Calling)实用性分析 - 以媒体信息解析为例
https://www.yooo.ltd/2024/09/21/parcticality-analysis-of-llm-tools/
作者
OrangeWolf
发布于
2024年9月21日
许可协议