ChatGLM3-6B开源了工具调用,好奇他是怎么实现的,所以写了这个文章记录。
官方给的示例很简单,只不过给的两个函数 track 和 text-to-speech 没有具体的实现,模型的输出也只是给出了需要调用的函数名和参数。剩下的需要自己去实现..
我更换了tools中的函数:
tools = [
{
"name": "go_ahead",
"description": "小车前进",
"parameters": {
"type": "object",
"properties": {
"distance": {
"description": "前进的距离,单位为米"
}
},
"required": ['distance']
}
},
{
"name": "back",
"description": "小车后退",
"parameters": {
"type": "object",
"properties": {
"distance": {
"description": "后退的距离,单位为米"
}
},
"required": ['distance']
}
},
{
"name": "turn_left",
"description": "小车左转",
"parameters": {
"type": "object",
"properties": {
"angle": {
"description": "左转角度,单位为°"
}
},
"required": ['angle']
}
},
{
"name": "turn_right",
"description": "小车右转",
"parameters": {
"type": "object",
"properties": {
"angle": {
"description": "右转角度,单位为°"
}
},
"required": ['angle']
}
}
]
测试下来出现以下问题:
1. 输入多个操作只能执行一个操作
2. 会出现输出不存在的函数的情况
3. 当已有的函数不能实现用户的操作时,会调用已有函数强行输出
现在让我们来看看具体实现的代码。下载chatglm3-6b权重的时候也会下载modeling_chatglm.py和tokenization_chatglm.py这两个python文件,chatglm3实现function calling也是在这里面实现的。
首先工具调用跟一般的对话的输入差在有一个 system_info ,他是作为history输入到model.chat函数中的。
system_info = {"role": "system", "content": "Answer the following questions as best as you can. You have access to the following tools:", "tools": tools}
我们可以在modeling_chatglm.py文件中找到chat的实现
@torch.inference_mode()
def chat(self, tokenizer, query: str, history: List[Tuple[str, str]] = None, role: str = "user",
max_length: int = 8192, num_beams=1, do_sample=True, top_p=0.8, temperature=0.8, logits_processor=None,
**kwargs):
if history is None:
history = []
if logits_processor is None:
logits_processor = LogitsProcessorList()
logits_processor.append(InvalidScoreLogitsProcessor())
gen_kwargs = {"max_length": max_length, "num_beams": num_beams, "do_sample": do_sample, "top_p": top_p,
"temperature": temperature, "logits_processor": logits_processor, **kwargs}
inputs = tokenizer.build_chat_input(query, history=history, role=role)
inputs = inputs.to(self.device)
eos_token_id = [tokenizer.eos_token_id, tokenizer.get_command("<|user|>"),
tokenizer.get_command("<|observation|>")]
outputs = self.generate(**inputs, **gen_kwargs, eos_token_id=eos_token_id)
outputs = outputs.tolist()[0][len(inputs["input_ids"][0]):-1]
response = tokenizer.decode(outputs)
history.append({"role": role, "content": query})
response, history = self.process_response(response, history)
return response, history
在chat函数中,history又被作为参数送到tokenizer.build_chat_input中,然后得到input。
那很明显需要查看tokenizer.build_chat_input的实现,tokenizer.build_chat_input函数在tokenization_chatglm中:
def build_chat_input(self, query, history=None, role="user"):
if history is None:
history = []
input_ids = []
for item in history:
content = item["content"]
if item["role"] == "system" and "tools" in item:
content = content + "\n" + json.dumps(item["tools"], indent=4, ensure_ascii=False)
input_ids.extend(self.build_single_message(item["role"], item.get("metadata", ""), content))
input_ids.extend(self.build_single_message(role, "", query))
input_ids.extend([self.get_command("<|assistant|>")])
return self.batch_encode_plus([input_ids], return_tensors="pt", is_split_into_words=True)
根据上面的代码看得出来,他是直接用json.dumps把tools拼接到content中,然后塞给大模型的。
输出的处理在chat函数中的process_response函数
def process_response(self, output, history):
content = ""
history = deepcopy(history)
for response in output.split("<|assistant|>"):
metadata, content = response.split("\n", maxsplit=1)
if not metadata.strip():
content = content.strip()
history.append({"role": "assistant", "metadata": metadata, "content": content})
content = content.replace("[[训练时间]]", "2023年")
else:
history.append({"role": "assistant", "metadata": metadata, "content": content})
if history[0]["role"] == "system" and "tools" in history[0]:
content = "\n".join(content.split("\n")[1:-1])
def tool_call(**kwargs):
return kwargs
parameters = eval(content)
content = {"name": metadata.strip(), "parameters": parameters}
else:
content = {"name": metadata.strip(), "content": content}
return content, history
这里需要注意一点,chatglm3-6b应该是有针对工具调用进行训练,输出的结果很稳定,基本上都是下面的结构:
'turn_right\n```python\ntool_call(angle=30)\n```'
第一行是调用的函数名,然后下面是执行函数的代码(代码中函数名统一为tool_call)。再通过split('\n')得到代码,eval执行tool_call函数得到函数的变量字典,然后返回字典如下:
{'name': 'turn_right', 'parameters': {'angle': 30}}
官方还给出了openai_api_demo.py这个文件,他实现了完整的 输入自然语言->得到函数和函数参数->执行函数 这一套流程。虽然不知道为什么没有在readme中写出来
openai_api_demo.py主要依靠tool_register.py下的get_tools和dispatch_tool
1. register_tool用于注册函数,它接受一个可调用对象 func 作为参数。该函数将 func 注册为一个工具,并返回 func 本身。
2. dispatch_tool用于执行函数,它接受一个函数名和函数参数,返回函数的返回。
我是在baichaun-13B上进行测试的,相对于chatglm3-6b每次稳定的输出,baichaun-13B的输出就不是那么好了。所以我们需要设计一个prompt如下:
prompt = '''
输出必须是带有markdown格式的python代码:
```python
工具的name(parameters)
```
例如:
```python
back(distance=10)
```'''
那么输入到百川模型的messages如下:
system_info = {"role": "system", "content": "尽可能回答以下问题。您可以使用以下工具:\n tools:" + json.dumps(tools, indent=4, ensure_ascii=False) + prompt}
query = "23加69等于多少"
messages = [
system_info,
{"role": "user", "content": query}
]
没有意外的话模型会生成一个被```python```包裹的代码,使用eval()执行代码就可以了