技术笔记-LLM的格式化输出和工具调用
调用外部工具是Agent区别于LLM的最重要区别之一。为了调用外部工具,需要做的主要有两件事:
- 针对用户输入的要求,从工具池中选择合适的工具,当然也可以选择不调用工具。这里考察的是LLM的reasoning的能力,一般来说,越大的模型效果越好。
- 对于选择的工具,要传入正确的参数(包括格式和内容)。这条主要考察LLM的格式化输出能力,例如传递的参数可能是个json格式,需要传递几个参数。这个能力需要LLM在相关格式的数据上有预训练,此外还需要一个后处理来保证格式化的输出。
为了实现上述两条,我们在具体实现中需要进行Task Planning(Reasoning)和Function Call (Parameter Parsing)。测试了下面几种方法:
- Text Prompt + FewShot做格式化输出

基于上面的role,我们输入User_input=”User:[提醒我晚上跑步]”,用下面的代码调用LLM
client = openai.OpenAI(api_key=openai.api_key, base_url = openai.api_base, )
completion = client.chat.completions.create(
model = self.model_name,
messages = [
{"role": "system", "content": prompt_role},
{"role": "user", "content": user_input}
],
temperature=OPENAI_CONFIG["temperature"],
max_tokens=OPENAI_CONFIG["max_tokens"],
)
我们测试kimi的moonshot-v1-8k和gpt3.5两个LLM,对比他们是否能输出正确的内容和格式。先说结论,内容都能正确输出,格式经常出错。目测下来,kimi成功率大约80%。Gpt3.5大约50%。
正确的输出结果应该是:
{
"Target": "ToPlanner",
"Content": "请增加晚上跑步提醒"
}
错误案例1:{
["ToPlanner"],
["请增加晚上跑步提醒日程"]
}
错误案例2:“ToPlanner: 请增加晚上9点的跑步日程。”
2. OpenAI API中response_format参数做格式化输出
OpenAI api中有一个参数叫做response_format. 默认情况下,response_format={"type": "text"}。当我们希望输出是json的时候,可以传入参数response_format={"type": "json_object"},这时候输入的prompt不变,输出结果测试下来有改善,但是仍然无法保证正确。例如下面的结果:
{
["Target": "ToPlanner"],
["Content": "请增加晚上跑步提醒日程"]
}
或者下面的错误(前面多了’’’json等符号): '```json\n[\n {\n "药物名称": "华法林",\n "用药频率": "每日一次",\n "每次剂量": "3mg",\n "注意事项": "长期服用,需定期监测INR值,避免与其他抗凝药物或酒精同时摄入"\n },\n {\n "药物名称": "阿司匹林",\n "用药频率": "每日一次",\n "每次剂量": "100mg",\n "注意事项": "早饭后服用,长期服用,预防血栓形成"\n },\n {\n "药物名称": "氯吡格雷",\n "用药频率": "每日两次",\n "每次剂量": "75mg",\n "注意事项": "早晚饭后服用,作为双联抗血小板治疗的一部分,帮助'
还有一个用了response_format={"type": "json_object"}的问题。就是这个format只能支持json object,不能支持json array。例如,我希望的输出是
[
{
"药物名称": "华法林",
"用药频率": "每日一次",
"每次剂量":"3mg",
"注意事项": "长期服用,需定期监测INR值,避免与其他抗凝药物或酒精同时摄入"
},
{
"药物名称": "阿司匹林",
"用药频率": "每日两次",
"每次剂量":"100mg",
"注意事项": "饭后服用,长期服用,预防血栓形成"
}
]
然而实际输出的是
{
"药物名称": "华法林",
"用药频率": "每日一次",
"每次剂量":"3mg",
"注意事项": "长期服用,需定期监测INR值,避免与其他抗凝药物或酒精同时摄入"
}
最新的 openai api 支持多种Structured Outputs,甚至是自己的数据格式,但是只支持大概24年8月以后的模型 。例如JSON Schema,但是这个只在gpt-4o-mini-2024-07-18以及gpt-4o-2024-08-06之后的模型中支持。据说效果很好。有待以后有时间测试。
https://platform.openai.com/docs/guides/structured-outputs/introduction
总结:结构化输出的功能,用openai的api还是要跟他支持的LLM配合使用。Kimi等其他家模型,还要等他们官方支持。
3. 用OpenAI的api自带的tool的参数实现Function Call
首先需要把等待调用的functions用下面的格式封装。tools=[
{ 'type': 'function',
'function': {
'name': 'ScheduleEdit',
'description': '日程编辑,包括增加,删除或者修改。',
'parameters': {
'parameters': {
'type': 'object',
'required': ['query'],
'properties': {
'query': { 'type': 'string', 'description': '\n 日程编辑的请求。包含请求的任务类型(增加,删除或者修改),编辑的日程时间和日程内容。 }
} } } } },
{ 'type': 'function',
'function': {
'name': 'ScheduleSearch',
'description': '查询用户的一个或者一组日程的具体内容,包括时间,内容,注意事项。',
'parameters': {
'type': 'object',
'required': ['query'],
'properties': {
'query': {'type': 'string', 'description': '\n 查询日程的请求,例如查找健身的时间,查找未完成的日程。请从用户的提问或聊天上下文提取。\n '}
} } } }}
]
然后调用方法只是加上tools参数:
completion = client.chat.completions.create(
model = self.model_name,
messages = [
{"role": "system", "content": prompt_role},
{"role": "user", "content": user_input}
],
tools=tools,
)
这里我们还是测试一下kimi和gpt3.5(注意,gpt3.5的测试结果可能有问题,见后面的说明)
prompt_role='根据用户的输入,选择合适的工具'
User_input= '请增加晚上跑步提醒'
设置tools参数之后,输出结果会按照json格式输出:
print(completion.choices[0].model_dump_json(indent=4))
{
"finish_reason": "stop",
"index": 0, "logprobs": null,
"message": {
"content": "当然,我可以帮您设置晚上跑步的提醒。请问您希望设置提醒的具体时间是什么时候呢?",
"refusal": null, "role": "assistant", "function_call": null, "tool_calls": null }
}
这个gpt3.5 的回答我想当满意,gpt居然理解到了user_input中没有具体的时间,所以让用户给出具体时间。不过很快就打脸了,多跑了几次,发现上面这个结果是个随机的。下面这个是出现的另一个随机的结果。可以看出,gpt3.5总是认为不调用工具,而且这次开始胡说八道了。这个可能是因为模型本身的问题,只有新的model才支持tool功能。
{ "finish_reason": "length", "index": 0, "logprobs": null, "message": { "content": "当然可以!以下是一个晚上跑步提醒的设置建议:\n\n1. **设定时间**:选择一个固定的时间,比如晚上7点或8点。\n\n2. **使用手机提醒**:\n - 打开手机的日历或提醒事项应用。\n - 创建一个每天重复的提醒,内容可以写成“晚上跑步时间到了!准备出发吧!”\n\n3. **使用健身应用**:\n - 下载一款健身应用,许多应用都有定时提醒功能。\n - 设置晚上跑步的计划,并开启通知。\n\n4. **社交媒体或朋友**:\n - 可以和朋友约定一起跑步,互相提醒。\n - 在社交媒体上发布你的跑步计划,增加动力。\n\n5. **视觉提醒**:\n - 在家里", "refusal": null, "role": "assistant", "function_call": null, "tool_calls": null } }
同样的代码用Kimi的结果:
{ "finish_reason": "tool_calls", "index": 0, "logprobs": null, "message": { "content": "", "refusal": null, "role": "assistant", "function_call": null, "tool_calls": [ { "id": "ScheduleEdit:0", "function": { "arguments": "{}", "name": "ScheduleEdit" }, "type": "function", "index": 0 } ] } }
Kimi正确的分析到应该调用ScheduleEdit工具。这个没有问题,但是没有得到输入的input的值,这里会导致工具调用的异常。测了多次kimi,基本都会稳定的得到上面的错误结果。
简单总结,用openai api中自带的tool参数,支持工具的格式化输入和格式化输出,更方便做后处理,比如从json中很容易获取到函数的名称,函数的参数字段。但是,实际效果上,仍然会出现调用失败,参数错误的问题。目测下来,实际效果不一定比直接用文本prompt的方法好太多。主要还是取决于原始模型,和prompt的能力。要跟合适的模型去匹配。要根据模型来选择用哪种方法。有的模型在这种格式化的数据上专门训练过就会好一点。需要case-by-case分析,不能一概而论。
注意,openai api只支持新的模型,我这里测试的是gpt3.5,无法调用工具是自然的。不过代码没有报错,只是不输出工具调用。听说gpt新的模型效果非常好。以后有时间这里再补充测试。
https://platform.openai.com/docs/guides/function-calling
4. 用LangChain调用工具
LangChain 对多个LLM进行了外部封装,使用统一的接口进行llm的调用,工具的调用,参数的解析,以及agent的实现。LangChain中提供了跟上述用OpenAI的api一样的功能,用bind_tools实现,支持多个LLM,例如gpt4,Qwen。
- 方法是首先创建一个LLM例如llm = ChatOpenAI(xxx),然后用llm.bind_tools()就可以了。这个封装的好处虽然说兼容了多个LLM,但是可能实际应用中帮助不大,因为大部分时候我们也就用选好的有限的模型,直接调官方API更好调试。
- 但是,如果用LangChain构建智能体,代码就可以简化很多。例如,
llm = OpenAI(temperature=0, model_name="gpt-3.5-turbo-instruct")
react_agent = initialize_agent(tools, llm, agent=AgentType.REACT_DOCSTORE, verbose=True)
只要几行代码,langchain就可以实现基于react等方法的agent,不需要自己实现react内部的memory,工具调用,参数解析。
5. 源代码实现ReWoo/ReAct等,自己写代码进行参数解析
开头我们说过,调用工具最主要的问题就在于强大的reasoning能力和格式化输出。前者目前的LLM能力都不错,特别是大的LLM,但是后者则大多数都表现不好,所以agent目前跟环境交互的成功率都不高,为了提高成功率旺旺需要多次重复调用LLM,消耗大量的token。因此,如果我们的智能体比较简单,场景确定,LLM不需要频繁更换,function不需要频繁更换,参数类型相对固定,那自己实现一套工具调用可能更加灵活,容易debug和优化。例如,我们这里参考ReWoo,自己可以实现agent的planning,memory,工具调用,和参数解析。
首先,我这里定义Planner输出的格式如下:
SCHEDULER_PWS = '''
User:提醒我晚上做一下八段锦和吃降压药。
Plan:需要增加晚上做八段锦的日程
#E1 = ScheduleEdit[增加日程-晚上做八段锦]
Plan: 需要增加晚上吃降压药的日程
#E2 = ScheduleEdit[增加日程-晚上吃降压药]
'''
我们规定了每个Plan的输出格式,把上述格式要求和Tools的name和description一起作为prompt让LLM进行planning。当然,如果我们使用gpt系列较新的模型,也可以用他的tool和格式化输出的能力实现。目前,只基于prompt engineering,我们需要自己实现字符串解析和异常处理,然后再传参调用函数,例如:
for line in response.splitlines():
if line.startswith("#") and line[1] == "E" and line[2].isdigit():
e, tool_call = line.split("=", 1)
e, tool_call = e.strip(), tool_call.strip()
if len(e) == 3:
evidences[e] = tool_call
else:
evidences[e] = "No evidence found"
posted on 2025-02-20 17:01 ExplorerMan 阅读(21) 评论(0) 编辑 收藏 举报
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
2024-02-20 【Go-Lua】Golang嵌入Lua代码——gopher-lua
2024-02-20 百度搜索exgraph图执行引擎设计与实践
2024-02-20 gengine简介
2024-02-20 DAG(有向无环图)易懂介绍
2019-02-20 一张图搞定OAuth2.0
2019-02-20 OAuth2.0的refresh token
2019-02-20 ACCESS_TOKEN与FRESH_TOKEN