问题引入
不知道大家有没有在B站上看过AI狼人杀的视频?视频中虽然各个AI通过推理进行狼人杀游戏,但是各个AI自身依然保持着“你问我答”的回合制局面。
然而就在上个月,有研究者就提出了“让AI决定自己什么时候能说话”:论文地址
原理实际上很简单:在每次AI生成之前,都预先进行一轮对话:让AI自己决定要不要加入到对话中。如果选择加入,再进入对话生成的进程中。如此这样不断地调用对应函数,使得AI能够更像人一样,异步地进行对话。同时让AI动态平衡,若AI发言频率低于群体均值,提示“更积极”。除此之外还模拟了AI打字的速度。
核心的代码以及实验结论可以在github中看到。
作者还在他的代码里写了另外3种策略:先生成后判断是否发送、固定频率进行生成、使用微调后的模型。
可视化复用
复用后的git链接
我想要复用这些代码;同时加入可视化要素(Gradio中游玩)。
由于我没有多人游玩的条件,所以我决定在原作者的基础上进行如下改变:
1、从1个AI多个真人变为1个真人多个AI
2、让各个不同的AI之间使用不同的发言频率。
主进程
原作者的游戏大致思路是:不同的玩家进行的操作都会写入特定的txt文件中。每个阶段主持人bot只需要读这些txt文件就能进行不同的游戏阶段推进。这部分我不需要做过多的更改。
import json
import os
from GameConsts import *
from dataclasses import dataclass, asdict, field
from pathlib import Path
game_dir = Path(os.curdir+"/Game/")
@dataclass
class PlayerConfig:
name: str # player's code name from the game's pool (in constants file)
is_mafia: bool = False
is_llm: bool = False
real_name: str = ""
llm_config: dict = field(default_factory=dict)
def init_game():
for filename in os.listdir(os.curdir+"/Game/"):
file_path = os.path.join(os.curdir+"/Game/", filename)
os.remove(file_path)
with open("config.json", "r",encoding="utf-8") as original_file:
config = json.load(original_file)
config["config_original_path_when_game_created"] = str(game_dir/"config.json")
with open(game_dir / GAME_CONFIG_FILE, "w",encoding="utf-8") as output_file:
json.dump(config, output_file, indent=4, ensure_ascii=False)
players = [PlayerConfig(**player_config) for player_config in config[PLAYERS_KEY_IN_CONFIG]]
all_names_str = "\n".join([player.name for player in players])
(game_dir / PLAYER_NAMES_FILE).write_text(all_names_str,encoding="utf-8")
(game_dir / REMAINING_PLAYERS_FILE).write_text(all_names_str,encoding="utf-8")
all_mafia_names_str = [player.name for player in players if player.is_mafia]
(game_dir / MAFIA_NAMES_FILE).write_text("\n".join(all_mafia_names_str),encoding="utf-8")
real_name_to_codename_str = [f"{player.real_name}{REAL_NAME_CODENAME_DELIMITER}{player.name}"
for player in players if not player.is_llm]
(game_dir / REAL_NAMES_FILE).write_text("\n".join(real_name_to_codename_str),encoding="utf-8")
(game_dir / PHASE_STATUS_FILE).write_text(DAYTIME,encoding="utf-8")
(game_dir / PUBLIC_MANAGER_CHAT_FILE).touch()
(game_dir / PUBLIC_DAYTIME_CHAT_FILE).touch()
(game_dir / PUBLIC_NIGHTTIME_CHAT_FILE).touch()
(game_dir / WHO_WINS_FILE).touch()
(game_dir / GAME_START_TIME_FILE).touch()
(game_dir / NOTES_FILE).touch()
for player in players:
(game_dir / PERSONAL_CHAT_FILE_FORMAT.format(player.name)).touch()
(game_dir / PERSONAL_VOTE_FILE_FORMAT.format(player.name)).touch()
(game_dir / PERSONAL_STATUS_FILE_FORMAT.format(player.name)).touch()
if player.is_llm:
(game_dir / LLM_LOG_FILE_FORMAT.format(player.name)).touch()
else:
(game_dir / PERSONAL_SURVEY_FILE_FORMAT.format(player.name)).touch()
# since for some reason the `mode` arg in mkdir doesn't work properly:
# os.system(f"chmod -R 777 {game_dir}")
print(f"Successfully created a new game dir in: {game_dir.absolute()}")
class Player:
def __init__(self, name, is_mafia, **kwargs):
self.name = name
self.is_mafia = is_mafia
self.personal_chat_file = game_dir / PERSONAL_CHAT_FILE_FORMAT.format(self.name)
self.personal_chat_file_lines_read = 0
self.personal_vote_file = game_dir / PERSONAL_VOTE_FILE_FORMAT.format(self.name)
self.personal_vote_file_lines_read = 0
# status is whether the player has joined and then whether was voted out
self.personal_status_file = game_dir / PERSONAL_STATUS_FILE_FORMAT.format(self.name)
def get_new_messages(self):
with open(self.personal_chat_file, "r",encoding="utf-8") as f:
# the readlines method includes the "\n"
lines = f.readlines()[self.personal_chat_file_lines_read:]
self.personal_chat_file_lines_read += len(lines)
return lines
def get_voted_player(self):
all_votes = self.personal_vote_file.read_text(encoding="utf-8").splitlines()
new_votes = all_votes[self.personal_vote_file_lines_read:]
if new_votes:
self.personal_vote_file_lines_read += len(new_votes) # should be 1 if works correctly
return new_votes[-1].strip()
else:
return None
def eliminate(self):
self.personal_status_file.write_text(VOTED_OUT,encoding="utf-8")
def get_config():
with open(game_dir / GAME_CONFIG_FILE, "r",encoding="utf-8") as f:
config = json.load(f)
return config
def get_players(config):
return [Player(**player_config) for player_config in config[PLAYERS_KEY_IN_CONFIG]]
def is_win_by_bystanders(mafia_players):
if len(mafia_players) == 0:
(game_dir / WHO_WINS_FILE).write_text("旁观者获胜",encoding="utf-8")
return True
return False
def is_win_by_mafia(mafia_players, bystanders):
if len(mafia_players) >= len(bystanders):
(game_dir / WHO_WINS_FILE).write_text("黑手党获胜",encoding="utf-8")
return True
return False
def is_game_over(players):
mafia_players = [player for player in players if player.is_mafia]
bystanders = [player for player in players if not player.is_mafia]
return is_win_by_bystanders(mafia_players) or is_win_by_mafia(mafia_players, bystanders)
def run_chat_round_between_players(players, chat_room):
for player in players:
lines = player.get_new_messages()
with open(chat_room, "a",encoding="utf-8") as f:
f.writelines(lines) # lines already include "\n"
def notify_players_about_voting_time(phase_name, public_chat_file):
phase_end_message = DAYTIME_VOTING_TIME_MESSAGE if phase_name == DAYTIME else NIGHTTIME_VOTING_TIME_MESSAGE
with open(public_chat_file, "a",encoding="utf-8") as f: # only to the current phase's active players chat room
f.write(format_message(GAME_MANAGER_NAME, phase_end_message))
voting_phase_name = DAYTIME_VOTING_TIME if phase_name == DAYTIME else NIGHTTIME_VOTING_TIME
(game_dir / PHASE_STATUS_FILE).write_text(voting_phase_name,encoding="utf-8")
def get_voted_out_name(optional_votes_players, public_chat_file, voting_players):
votes = {player.name: 0 for player in optional_votes_players}
while voting_players:
voted_players = []
for player in voting_players:
voted_for = player.get_voted_player()
if not voted_for:
continue
voted_players.append(player)
if voted_for in votes:
with open(public_chat_file, "a",encoding="utf-8") as f:
voting_message = VOTING_MESSAGE_FORMAT.format(player.name, voted_for)
f.write(format_message(GAME_MANAGER_NAME, voting_message))
votes[voted_for] += 1
for player in voted_players:
voting_players.remove(player)
# if there were invalid votes or if there was a tie, decision will be made "randomly"
voted_out_name = max(votes, key=votes.get)
return voted_out_name
def voting_sub_phase(phase_name, voting_players, optional_votes_players, public_chat_file, players):
notify_players_about_voting_time(phase_name, public_chat_file)
voted_out_name = get_voted_out_name(optional_votes_players, public_chat_file, voting_players[:])
# update info file of remaining players
remaining_players = (game_dir / REMAINING_PLAYERS_FILE).read_text(encoding="utf-8").splitlines()
remaining_players.remove(voted_out_name)
(game_dir / REMAINING_PLAYERS_FILE).write_text("\n".join(remaining_players),encoding="utf-8")
# update player object status
voted_out_player = {player.name: player for player in optional_votes_players}[voted_out_name]
voted_out_player.eliminate()
players.remove(voted_out_player)
announce_voted_out_player(voted_out_player)
def game_manager_announcement(message):
with open(game_dir / PUBLIC_MANAGER_CHAT_FILE, "a",encoding="utf-8") as f:
f.write(format_message(GAME_MANAGER_NAME, message))
def get_role_string(is_mafia):
return MAFIA_ROLE if is_mafia else BYSTANDER_ROLE
VOTED_OUT_MESSAGE_FORMAT = "{}被投票出局。他的角色是{}"
def announce_voted_out_player(voted_out_player):
role = get_role_string(voted_out_player.is_mafia)
voted_out_message = VOTED_OUT_MESSAGE_FORMAT.format(voted_out_player.name, role)
game_manager_announcement(voted_out_message)
CUTTING_TO_VOTE_MESSAGE = "只剩1位黑手党了,无需讨论直接投票!"
def run_phase(players, voting_players, optional_votes_players, public_chat_file,
time_limit_seconds, phase_name):
if len(voting_players) > 1:
start_time = time.time()
while time.time() - start_time < time_limit_seconds:
run_chat_round_between_players(voting_players, public_chat_file)
else:
game_manager_announcement(CUTTING_TO_VOTE_MESSAGE)
print("Now voting starts...")
voting_sub_phase(phase_name, voting_players, optional_votes_players, public_chat_file, players)
def minutes_to_seconds(num_minutes):
return int(num_minutes * 60)
def run_nighttime(players, nighttime_minutes):
(game_dir / PHASE_STATUS_FILE).write_text(NIGHTTIME,encoding="utf-8")
mafia_players = [player for player in players if player.is_mafia]
bystanders = [player for player in players if not player.is_mafia]
print(colored(NIGHTTIME_START_MESSAGE_FORMAT.format(nighttime_minutes), NIGHTTIME_COLOR))
game_manager_announcement(NIGHTTIME_START_MESSAGE_FORMAT.format(nighttime_minutes))
run_phase(players, mafia_players, bystanders, game_dir / PUBLIC_NIGHTTIME_CHAT_FILE,
minutes_to_seconds(nighttime_minutes), NIGHTTIME)
def run_daytime(players, daytime_minutes):
(game_dir / PHASE_STATUS_FILE).write_text(DAYTIME,encoding="utf-8")
print(colored(DAYTIME_START_MESSAGE_FORMAT.format(daytime_minutes), DAYTIME_COLOR))
game_manager_announcement(DAYTIME_START_MESSAGE_FORMAT.format(daytime_minutes))
run_phase(players, players, players, game_dir / PUBLIC_DAYTIME_CHAT_FILE,
minutes_to_seconds(daytime_minutes), DAYTIME)
def wait_for_players(players):
havent_joined_yet = [player for player in players]
print("Waiting for all players to connect and start running their programs to join:")
print(", ".join([player.name for player in havent_joined_yet]))
while havent_joined_yet:
joined = []
for player in havent_joined_yet:
if bool(player.personal_status_file.read_text(encoding="utf-8")): # file isn't empty once joined
joined.append(player)
print(f"{player.name} has joined!")
for player in joined:
havent_joined_yet.remove(player)
(game_dir / GAME_START_TIME_FILE).write_text(get_current_timestamp(),encoding="utf-8")
print("Game is now running! Its content is displayed to players.")
def get_all_player_out_of_voting_time():
current_phase = (game_dir / PHASE_STATUS_FILE).read_text(encoding="utf-8")
(game_dir / PHASE_STATUS_FILE).write_text(current_phase.replace(VOTING_TIME, ""),encoding="utf-8")
def end_game():
get_all_player_out_of_voting_time()
print("Game has finished.")
def main():
init_game()
config = get_config()
players = get_players(config)
wait_for_players(players)
while not is_game_over(players):
run_daytime(players, config[DAYTIME_MINUTES_KEY])
if is_game_over(players):
break
run_nighttime(players, config[NIGHTTIME_MINUTES_KEY])
end_game()
if __name__ == '__main__':
main()
AI Player
对于AI机器人而言,原作者设计了LLM类,给了它生成对话、写入txt文件以及投票等功能。根据不同的策略来继承这个母类。我只需要ScheduleThenGeneratePlayer方法就可以了。
class Logger:
def __init__(self, name: str, game_dir: Path):
LLM_LOG_FILE_FORMAT = "{}_log.txt"
self.log_file = game_dir / LLM_LOG_FILE_FORMAT.format(name)
def log(self, operation, content):
with open(self.log_file, "a",encoding="utf-8") as f:
f.write(NEW_LOG_FORMAT.format(time=time.strftime("%H:%M:%S"),
operation=operation, content=content))
class LLMWrapper:
def __init__(self, logger, **llm_config):
self.logger = logger
self.ipt_token = 0
self.output_token = 0
self.model_name = llm_config[MODEL_NAME_KEY]
self.enable_thinking = llm_config[ENABLE_THINKING]
self.generation_parameters = {key: value for key, value in llm_config.items() if key not in [ENABLE_THINKING,FREQUENCY,STYLE,MODEL_NAME_KEY,PASS_TURN_TOKEN_KEY,USE_TURN_TOKEN_KEY]}
if 'stream' in self.generation_parameters and self.generation_parameters["stream"]:
self.generation_parameters["stream_options"] = {"include_usage": True}
self.client = OpenAI(
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1/",
)
self.generate(INITIAL_GENERATION_PROMPT, system_info=GENERAL_SYSTEM_INFO)
def generate(self, input_text, system_info="", generation_parameters=None):
#if generation_parameters is None:
generation_parameters = self.generation_parameters
messages = [{"role": "system", "content": system_info}] if system_info else []
messages += [{"role": "user", "content": input_text}]
self.logger.log("messages in generate with self.use_together", messages)
final_output = self.generate_with_together_safely(messages, generation_parameters) # max_new_tokens -> max_tokens
self.logger.log("final_output in generate with self.use_together", final_output)
return final_output.replace("\n", " ").strip()
def generate_with_together_safely(self, messages, generation_parameters):
output = None
while not output:
try:
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
extra_body={"enable_thinking": self.enable_thinking},
**generation_parameters
)
if 'stream' in generation_parameters and generation_parameters["stream"]:
output = ""
for chunk in response:
if not chunk.choices:
continue
if chunk.choices[0].delta.content:
output += chunk.choices[0].delta.content
self.ipt_token += chunk.usage.prompt_tokens
self.output_token += chunk.usage.completion_tokens
else:
output = response.choices[0].message.content
self.ipt_token += response.usage.prompt_tokens
self.output_token += response.usage.completion_tokens
except Exception as e:
print(f"TogetherException\n{e}")
print(generation_parameters)
self.logger.log("error generating with TogetherAI", str(e))
time.sleep(SLEEPING_TIME_FOR_API_GENERATION_ERROR)
return output
class LLMPlayer(ABC):
TYPE_NAME = None
def __init__(self, name, is_mafia, llm_config, game_dir, **kwargs):
self.name = name
self.is_mafia = is_mafia
self.role = MAFIA_ROLE if is_mafia else BYSTANDER_ROLE
self.game_dir = game_dir
self.logger = Logger(name, game_dir)
self.pass_turn_token = llm_config[PASS_TURN_TOKEN_KEY]
self.use_turn_token = llm_config[USE_TURN_TOKEN_KEY]
self.llm = LLMWrapper(self.logger, **llm_config)
self.frequency = float(llm_config[FREQUENCY])
self.style = llm_config[STYLE]
def get_system_info_message(self, attention_to_not_repeat=False, only_special_tokens=False):
system_info = f"你的名字是{self.name}. {GENERAL_SYSTEM_INFO}\n" \
f"你被分配到的角色是: {self.role}.\n"
chat_room_open_time = (self.game_dir / GAME_START_TIME_FILE).read_text(encoding="utf-8").strip()
if chat_room_open_time: # if the game has started, the file isn't empty
system_info += f"游戏房间在{chat_room_open_time}开启.\n"
if attention_to_not_repeat:
# system_info += "Note: Do not repeat any messages already present in the message history below!\n"
system_info += RULES
previous_messages = (self.game_dir / PERSONAL_CHAT_FILE_FORMAT.format(self.name)
).read_text(encoding="utf-8").splitlines()
if previous_messages:
system_info += "以下是你以前发过的信息,请勿重复:"
for message in previous_messages:
matcher = re.match(MESSAGE_PARSING_PATTERN, message)
if not matcher:
continue
message_content = matcher.group(5) # depends on MESSAGE_PARSING_PATTERN
system_info += f"* \"{message_content}\"\n"
if only_special_tokens:
system_info += f"你只能在以下的2个选项(包括左右的<>)中进行回复\n" \
f"{self.pass_turn_token} - 代表你所扮演的角色" \
f"应当静观其变,等待并且不说任何话;\n" \
f"{self.use_turn_token} - 代表你所扮演的角色" \
f"应当现在就进行发言。\n\n" \
f"你绝不能输出任何其他文本、解释或者选项变化" \
f"只允许使用以下选项:" \
f"{self.pass_turn_token} 或 {self.use_turn_token}.\n"
return system_info
@abstractmethod
def should_generate_message(self, context):
raise NotImplementedError()
@abstractmethod
def generate_message(self, message_history):
raise NotImplementedError()
def interpret_scheduling_decision(self, decision):
if not decision:
generate = False
elif self.pass_turn_token in decision:
generate = False
elif self.use_turn_token in decision:
generate = True
# for more robustness:
# elif any([option in decision for option in PASS_TURN_TOKEN_OPTIONS]):
# generate = False
else:
generate = True
if generate:
self.logger.log(SCHEDULING_DECISION_LOG, MODEL_CHOSE_TO_USE_TURN_LOG)
else:
self.logger.log(SCHEDULING_DECISION_LOG, MODEL_CHOSE_TO_PASS_TURN_LOG)
return generate
def get_vote(self, message_history, candidate_vote_names):
task = f"在剩下的玩家中,你想要淘汰哪位?" \
f"请根据历史记录中的对话内容来回答。" \
f"请只回复一个名字,除了这个名字以外什么都不要回复"
task += ", ".join(candidate_vote_names)
prompt = turn_task_into_prompt(task, message_history)
system_info = self.get_system_info_message()
self.logger.log("prompt for get_vote", prompt)
self.logger.log("system_info for get_vote", system_info)
vote = self.llm.generate(prompt, system_info)
self.logger.log("generated vote in get_vote", vote)
return vote
def no_one_has_talked_yet_in_current_phase(message_history):
if not message_history:
return True
matcher = re.match(MESSAGE_PARSING_PATTERN, message_history[-1])
if not matcher:
return True
name = matcher.group(4) # depends on MESSAGE_PARSING_PATTERN
return name == GAME_MANAGER_NAME
class ScheduleThenGeneratePlayer(LLMPlayer):
TYPE_NAME = SCHEDULE_THEN_GENERATE_TYPE
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.scheduler = self.llm # using the same one for generation...
def should_generate_message(self, message_history):
if no_one_has_talked_yet_in_current_phase(message_history):
return False
prompt = self.create_scheduling_prompt(message_history)
self.logger.log("prompt in should_generate_message", prompt)
decision = self.scheduler.generate(
prompt, self.get_system_info_message(only_special_tokens=True),
SCHEDULING_GENERATION_PARAMETERS)
self.logger.log("decision in should_generate_message", decision)
return self.interpret_scheduling_decision(decision)
def generate_message(self, message_history):
if self.should_generate_message(message_history):
prompt = self.create_generation_prompt(message_history)
self.logger.log("prompt in generate_message", prompt)
message = self.llm.generate(
prompt, self.get_system_info_message(attention_to_not_repeat=True))
message = make_more_human_like(message)
return message
else:
return ""
def talkative_scheduling_prompt_modifier(self, message_history):
if not message_history or is_nighttime(self.game_dir):
return TALKATIVE_PROMPT
all_players = (self.game_dir / REMAINING_PLAYERS_FILE).read_text(encoding="utf-8").splitlines()
players_counts = {player: 0 for player in all_players}
for message in message_history[::-1]:
for player in players_counts:
if f"] {player}: " in message:
players_counts[player] += 1
all_player_messages = sum(players_counts.values())
if not all_player_messages or players_counts[self.name] / all_player_messages < self.frequency:
return TALKATIVE_PROMPT
else:
return QUIETER_PROMPT
def create_scheduling_prompt(self, message_history):
# removed these because of too many talks:
# "If one of the last messages has mentioned you, then choose to send a message now."
task = f"你现在是想要向其他玩家发送消息,还是静观其变,看看其他人会发送什么消息?" \
f"只有当你觉得对当前讨论的贡献足够有意义时,才选择发送消息。" \
f"{self.talkative_scheduling_prompt_modifier(message_history).strip()} " \
f"根据现在的讨论,如果你想发送消息的话请只回复`{self.use_turn_token}`(包括左右的<>)," \
f"如果想要静观其变的话请只回复`{self.pass_turn_token}`(包括左右的<>)。 "
return turn_task_into_prompt(task, message_history)
def create_generation_prompt(self, message_history):
task = f"请在游戏聊天中加入一条的消息,只要给出语言就行了,不要给出动作、神态或其他描写,就好像是网上聊天发出的消息一样。" \
f"字数不要超过100字,但是不要再回复中写字数的统计信息。"\
f"请根据最后的消息以及现在的游戏状态,给出一个具体并且与现状相关的消息。" \
f"**重要:不要重复自己说过的话!" \
f"你的语言风格应当是:{self.style}"
return turn_task_into_prompt(task, message_history)
OPERATOR_COLOR = "yellow"
LLM_PLAYER_LOADED_MESSAGE = "AI玩家载入成功,正在等待其他玩家……"
ALL_PLAYERS_JOINED_MESSAGE = "所有玩家已经就绪,游戏开始。"
LLM_VOTE_MESSAGE_FORMAT = "AI玩家投票: {}"
GAME_ENDED_MESSAGE = "游戏结束!"
GET_LLM_PLAYER_NAME_MESSAGE = "本轮游戏配置有多个AI玩家,你希望使用哪一个?"
ELIMINATED_MESSAGE = "AI玩家被投票出局了..."
# global variable
game_dir = Path() # will be updated in get_llm_player
def get_llm_player(role):
global game_dir
game_dir = Path(os.curdir+"/Game/")
with open(game_dir / GAME_CONFIG_FILE,encoding="utf-8") as f:
config = json.load(f)
llm_players_configs = [player for player in config[PLAYERS_KEY_IN_CONFIG] if player["is_llm"]]
if not llm_players_configs:
raise ValueError("本游戏没有配置AI玩家")
elif len(llm_players_configs) == 1:
player_config = llm_players_configs[0]
else:
# player_name = get_player_name_from_user([player["name"] for player in llm_players_configs],
# GET_LLM_PLAYER_NAME_MESSAGE, OPERATOR_COLOR)
player_name = role
player_config = [player for player in llm_players_configs
if player["name"] == player_name][0]
player_config[GAME_DIR_KEY] = game_dir
llm_player = ScheduleThenGeneratePlayer(**player_config)
(game_dir / PERSONAL_STATUS_FILE_FORMAT.format(llm_player.name)).write_text(JOINED,encoding="utf-8")
return llm_player
def read_messages_from_file(message_history, file_name, num_read_lines):
with open(game_dir / file_name, "r",encoding="utf-8") as f:
lines = f.readlines()[num_read_lines:]
message_history.extend(lines)
return len(lines)
def wait_writing_time(player, message):
time.sleep(MAX_TIME_TO_WAIT)
def eliminate(player):
# currently doesn't use player, but maybe in the future we can use player.logger for example
print(colored(ELIMINATED_MESSAGE, OPERATOR_COLOR))
def get_vote_from_llm(player, message_history):
candidate_vote_names = (game_dir / REMAINING_PLAYERS_FILE).read_text(encoding="utf-8").splitlines()
candidate_vote_names.remove(player.name)
voting_message = player.get_vote(message_history, candidate_vote_names)
for name in candidate_vote_names:
if name in voting_message: # update game manger
update_vote(name, player)
return
# if didn't return: no name was in voting_message
player.logger.log(MODEL_VOTED_INVALIDLY_LOG, voting_message)
print(colored(MODEL_VOTED_INVALIDLY_LOG + ": " + voting_message, OPERATOR_COLOR))
vote = random.choice(candidate_vote_names)
player.logger.log(MODEL_RANDOMLY_VOTED_LOG, vote)
update_vote(vote, player)
def update_vote(voted_name, player):
time.sleep(VOTING_WAITING_TIME)
with open(game_dir / PERSONAL_VOTE_FILE_FORMAT.format(player.name), "a",encoding="utf-8") as f:
f.write(voted_name + "\n")
print(colored(LLM_VOTE_MESSAGE_FORMAT.format(voted_name), OPERATOR_COLOR))
def add_message_to_game(player, message_history):
is_nighttime_at_start = is_nighttime(game_dir)
if not player.is_mafia and is_nighttime_at_start:
return # only mafia can communicate during nighttime
message = player.generate_message(message_history).strip()
if is_time_to_vote(game_dir):
return # sometimes the messages is generated when it's already too late, so drop it
if message:
# artificially making the model taking time to write the message
wait_writing_time(player, message)
if is_nighttime(game_dir) != is_nighttime_at_start:
return # waited for too long
with open(game_dir / PERSONAL_CHAT_FILE_FORMAT.format(player.name), "a",encoding="utf-8") as f:
f.write(format_message(player.name, message))
print(colored(MODEL_CHOSE_TO_USE_TURN_LOG, OPERATOR_COLOR))
else:
print(colored(MODEL_CHOSE_TO_PASS_TURN_LOG, OPERATOR_COLOR))
def end_game():
print(colored(GAME_ENDED_MESSAGE, OPERATOR_COLOR))
def main(role):
player = get_llm_player(role)
print(colored(LLM_PLAYER_LOADED_MESSAGE, OPERATOR_COLOR))
while not all_players_joined(game_dir):
continue
print(colored(ALL_PLAYERS_JOINED_MESSAGE, OPERATOR_COLOR))
message_history = []
num_read_lines_manager = num_read_lines_daytime = num_read_lines_nighttime = 0
while not is_game_over(game_dir):
num_read_lines_manager += read_messages_from_file(
message_history, PUBLIC_MANAGER_CHAT_FILE, num_read_lines_manager)
# only current phase file will have new messages, so no need to run expensive is_nighttime()
num_read_lines_daytime += read_messages_from_file(
message_history, PUBLIC_DAYTIME_CHAT_FILE, num_read_lines_daytime)
if player.is_mafia: # only mafia can see what happens during nighttime
num_read_lines_nighttime += read_messages_from_file(
message_history, PUBLIC_NIGHTTIME_CHAT_FILE, num_read_lines_nighttime)
if is_voted_out(player.name, game_dir):
eliminate(player)
break
if is_time_to_vote(game_dir) and (player.is_mafia or not is_nighttime(game_dir)):
get_vote_from_llm(player, message_history)
while is_time_to_vote(game_dir):
continue # wait for voting time to end when all players have voted
add_message_to_game(player, message_history)
end_game()
with open(role+"Used Token.txt","w",encoding="utf-8") as f:
f.write(f"输入token:{player.llm.ipt_token} 输出token:{player.llm.output_token}")
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='选择角色')
parser.add_argument('--role', help='选择的角色')
args = parser.parse_args()
try:
main(args.role)
except Exception as e:
with open(f"{args.role}_error.txt","w") as f:
f.write(e)
Gradio可视化
我没有使用原作者对于人类写的交互类,反而是模仿原本的LLMPlayer类,写了一个Human类出来。在Gradio中,使用一个隐形的按钮来按特定间隔扫描所有的txt文件,从而让人能够在Gradio界面中展示对应的界面。
from pathlib import Path
import time
import os
import json
from GameConsts import *
from pathlib import Path
from AIPlayer import Logger
import gradio as gr
class Human:
def __init__(self, **params):
self.logger = Logger
self.name = params["name"]
self.is_mafia = params["is_mafia"]
def speak(self, s):
if generation_parameters is None:
generation_parameters = self.generation_parameters
self.logger.log("final_output", s)
return s
def get_vote(self,vote):
self.logger.log("generated vote in get_vote", vote)
return vote
OPERATOR_COLOR = "yellow"
HUMAN_PLAYER_LOADED_MESSAGE = "人类玩家初始化成功,正在等待其他玩家……"
ALL_PLAYERS_JOINED_MESSAGE = "所有玩家已经就绪,游戏开始。"
HUMAN_VOTE_MESSAGE_FORMAT = "人类玩家投票: {}"
GAME_ENDED_MESSAGE = "游戏结束!"
GET_HUMAN_PLAYER_NAME_MESSAGE = "本轮游戏配置有多个人类玩家,你希望使用哪一个?"
ELIMINATED_MESSAGE = "人类玩家被投票出局了..."
# global variable
game_dir = Path() # will be updated in get_llm_player
def get_human_player():
global game_dir
game_dir = Path(os.curdir+"/Game/")
with open(game_dir / GAME_CONFIG_FILE,encoding="utf-8") as f:
config = json.load(f)
human_players_configs = [player for player in config[PLAYERS_KEY_IN_CONFIG] if not player["is_llm"]]
if not human_players_configs:
raise ValueError("本游戏没有配置人类玩家")
elif len(human_players_configs) == 1:
player_config = human_players_configs[0]
else:
player_name = get_player_name_from_user([player["name"] for player in human_players_configs],
GET_HUMAN_PLAYER_NAME_MESSAGE, OPERATOR_COLOR)
player_config = [player for player in human_players_configs
if player["name"] == player_name][0]
player_config[GAME_DIR_KEY] = game_dir
human_player = Human(**player_config)
(game_dir / PERSONAL_STATUS_FILE_FORMAT.format(human_player.name)).write_text(JOINED,encoding="utf-8")
return human_player
def read_messages_from_file(message_history, file_name, num_read_lines):
with open(game_dir / file_name, "r",encoding="utf-8") as f:
lines = f.readlines()[num_read_lines:]
message_history.extend(lines)
return len(lines)
def wait_writing_time(player, message):
time.sleep(MAX_TIME_TO_WAIT)
def eliminate(player):
# currently doesn't use player, but maybe in the future we can use player.logger for example
print(colored(ELIMINATED_MESSAGE, OPERATOR_COLOR))
def update_vote(voted_name, player:Human):
time.sleep(VOTING_WAITING_TIME)
with open(game_dir / PERSONAL_VOTE_FILE_FORMAT.format(player.name), "a",encoding="utf-8") as f:
f.write(voted_name + "\n")
print(colored(HUMAN_VOTE_MESSAGE_FORMAT.format(voted_name), OPERATOR_COLOR))
def end_game():
print(colored(GAME_ENDED_MESSAGE, OPERATOR_COLOR))
class Interface_Gradio:
def __init__(self) -> None: #后续改为 左3AI右3AI中间2的布局
with open(game_dir / GAME_CONFIG_FILE,encoding="utf-8") as f:
self.config = json.load(f)
self.already_vote = False
self.last_vote_time = None
self.other_player_dict = {}
self.vote_other_player_btn = {}
self.message_history = []
self.num_read_lines_manager = self.num_read_lines_daytime = self.num_read_lines_nighttime = 0
self.player = None
self.is_alive = True
with open("autoclick.js") as f:
js_code = f.read()
with open("GradioCss.css") as f:
css = f.read()
with gr.Blocks(js = js_code,css=css) as demo:
with gr.Row():
for player in self.config[PLAYERS_KEY_IN_CONFIG]:
with gr.Column(scale=1,min_width=160):
self.other_player_dict[player["name"]] = gr.Chatbot(label=player["name"],type="messages",elem_classes="character")
self.vote_other_player_btn[player["name"]] = gr.Button(value=player["name"],visible=False)
for btn in self.vote_other_player_btn.values():
btn.click(fn=self.vote,inputs=btn,outputs=[i for i in self.vote_other_player_btn.values()])
with gr.Row():
self.other_player_dict["主持人"] = gr.Chatbot(label="主持人",type="messages",elem_classes="character")
with gr.Row():
self.input_box = gr.Textbox(label="信息",scale=4,interactive=False)
self.send_box = gr.Button("等待AI加入",scale=1,interactive=False)
self.input_box.submit(fn=self.add_message_to_game,inputs=self.input_box,outputs=self.input_box)
self.send_box.click(fn=self.add_message_to_game,inputs=self.input_box,outputs=self.input_box)
with gr.Row():
self.hidden_btn2 = gr.HTML("<div onclick=autorefresh() id=\"hidden_btn\">加入游戏<div/>",elem_id="to_be_hidden")
self.hidden_btn = gr.Button(visible=False,elem_id="sync")
self.hidden_btn.click(fn=self.scan_to_read,inputs=[],outputs=[self.input_box,self.send_box]+[i for i in self.vote_other_player_btn.values()]+[i for i in self.other_player_dict.values()],show_progress=False)
demo.launch()
def parse_message(self):
res_dict = {i:[] for i in self.other_player_dict.keys()}
for msg in self.message_history:
msg = msg[11:] #去掉时间
msg_list = msg.split(": ")
role = msg_list[0]
content = "".join([i for i in msg_list[1:]])
if role not in res_dict.keys():
raise NameError()
res_dict[role].extend([{"role":"assistant","content":content},{"role":"user","content":""}])
return res_dict
def vote(self,name):
with open(game_dir / PERSONAL_VOTE_FILE_FORMAT.format(self.player.name), "a",encoding="utf-8") as f:
f.write(name + "\n")
self.already_vote = True
if NIGHTTIME_VOTING_TIME in (game_dir / PHASE_STATUS_FILE).read_text(encoding="utf-8"):
self.last_vote_time = "Night"
else:
self.last_vote_time = "Day"
return [gr.update(visible=False)]*len(self.vote_other_player_btn.values())
def scan_to_read(self):
if self.player is None:
self.player = get_human_player()
if not all_players_joined(game_dir):
send_available = [gr.update(),gr.update()]
vote_btns = [gr.update()]*len(self.vote_other_player_btn.values())
join_status = []
for i in self.other_player_dict.keys():
if i == "主持人":
join_status.append(gr.update())
else:
status = bool((game_dir / PERSONAL_STATUS_FILE_FORMAT.format(i)).read_text(encoding="utf-8"))
join_status.append(gr.update(value=[{"role":"assistant","content":"已加入" if status else "等待加入..."}]))
return send_available+vote_btns+join_status
send_available = [gr.update(interactive=True),gr.update(interactive=True,value="发送")]
vote_btns = [gr.update(visible=False)]*len(self.vote_other_player_btn.values())
if not self.is_alive:
send_available = [gr.update(interactive=False),gr.update(interactive=False,value="已出局")]
self.num_read_lines_daytime += read_messages_from_file(
self.message_history, PUBLIC_DAYTIME_CHAT_FILE, self.num_read_lines_daytime)
if self.player.is_mafia: # only mafia can see what happens during nighttime
self.num_read_lines_nighttime += read_messages_from_file(
self.message_history, PUBLIC_NIGHTTIME_CHAT_FILE, self.num_read_lines_nighttime)
self.num_read_lines_manager+= read_messages_from_file(
self.message_history, PUBLIC_MANAGER_CHAT_FILE, self.num_read_lines_manager)
if is_voted_out(self.player.name, game_dir):
eliminate(self.player)
self.is_alive = False
if not(is_time_to_vote(game_dir)): #只剩一个Mafia时会略过
self.already_vote = False
current_time = "Night" if NIGHTTIME_VOTING_TIME in (game_dir / PHASE_STATUS_FILE).read_text(encoding="utf-8") else "Day"
if is_time_to_vote(game_dir) and (self.player.is_mafia or not is_nighttime(game_dir)) and self.is_alive and ((not self.already_vote) or current_time!=self.last_vote_time):
send_available = [gr.update(interactive=False),gr.update(interactive=False,value="请投票")]
candidate_vote_names = (game_dir / REMAINING_PLAYERS_FILE).read_text(encoding="utf-8").splitlines()
candidate_vote_names.remove(self.player.name)
for idx,btn in enumerate(list(self.vote_other_player_btn.values())):
if btn.value in candidate_vote_names:
vote_btns[idx] = gr.update(visible=True)
if is_game_over(game_dir):
self.is_alive = False
msg_role = self.parse_message()
msg_class_dict = {i:"character" for i in self.other_player_dict.keys()}
survive_names = (game_dir / REMAINING_PLAYERS_FILE).read_text(encoding="utf-8").splitlines()
# 如果是夜间,平民聊天框的底色变暗,人类平民不再可以发送信息
if is_nighttime(game_dir):
for player in self.config[PLAYERS_KEY_IN_CONFIG]:
if not player["is_mafia"]:
msg_class_dict[player["name"]] = "character_inactive"
if player["name"] == self.player.name:
send_available = [gr.update(interactive=False),gr.update(interactive=False,value="夜间平民无法说话")]
# 白天恢复
if (not is_nighttime(game_dir)) and (not is_time_to_vote(game_dir)) and self.is_alive:
send_available = [gr.update(interactive=True),gr.update(interactive=True,value="发送")]
# 死亡角色的背景框变成红色
for player in self.config[PLAYERS_KEY_IN_CONFIG]:
if player["name"] not in survive_names:
msg_class_dict[player["name"]] = "character_dead"
character_msg = []
for k,v in self.other_player_dict.items():
character_msg.append(gr.update(value = msg_role[k],elem_classes=msg_class_dict[k]))
if is_game_over(game_dir):
end_info = "游戏结束,"+(game_dir / WHO_WINS_FILE).read_text(encoding="utf-8")
send_available = [gr.update(interactive=False),gr.update(interactive=False,value=end_info)]
vote_btns = [gr.update(visible=False)]*len(self.vote_other_player_btn.values())
return send_available+vote_btns+character_msg
def add_message_to_game(self,message):
with open(game_dir / PERSONAL_CHAT_FILE_FORMAT.format(self.player.name), "a",encoding="utf-8") as f:
f.write(format_message(self.player.name, message))
return gr.update(value=None)
def main():
player = Interface_Gradio()
# print(colored(ALL_PLAYERS_JOINED_MESSAGE, OPERATOR_COLOR))
# message_history = []
# num_read_lines_manager = num_read_lines_daytime = num_read_lines_nighttime = 0
# while not is_game_over(game_dir):
# num_read_lines_manager += read_messages_from_file(
# message_history, PUBLIC_MANAGER_CHAT_FILE, num_read_lines_manager)
# # only current phase file will have new messages, so no need to run expensive is_nighttime()
# num_read_lines_daytime += read_messages_from_file(
# message_history, PUBLIC_DAYTIME_CHAT_FILE, num_read_lines_daytime)
# if player.is_mafia: # only mafia can see what happens during nighttime
# num_read_lines_nighttime += read_messages_from_file(
# message_history, PUBLIC_NIGHTTIME_CHAT_FILE, num_read_lines_nighttime)
# if is_voted_out(player.name, game_dir):
# eliminate(player)
# break
# if is_time_to_vote(game_dir) and (player.is_mafia or not is_nighttime(game_dir)):
# # get_vote_from_human(player, message_history)
# while is_time_to_vote(game_dir):
# continue # wait for voting time to end when all players have voted
# end_game()
if __name__ == '__main__':
main()
.pending{
display: none;
}
.message-wrap > .container{
display: none;
}
.user-row{
display: none;
}
.hidden{
display: none;
}
.progress-text {
display: none !important;
}
#to_be_hidden{
border:solid;
background-color: #e4e4e7;
text-align: center;
cursor: pointer;
}
#hidden{
display: none;
}
.character_inactive{
background-color: #e4e4e7;
}
.character_dead{
background-color: #ff676754;
}
async () => {
globalThis.autorefresh = () => {
let button = document.querySelector("#sync");
timer = setInterval(function() {
button.click();
}, 500);
let button2 = document.querySelector("#to_be_hidden");
button2.id = "hidden";
}
}
效果与展望
和作者一样,我选择了8B的模型。但是不同之处在于,作者实验是将1个AI放入杀人游戏,我则是只有1个人类,剩下的都是AI。同时,作者让每个AI都使用和其他人一样的频率,但是我给每个AI都设置了不同的频率。
然而,或许是由于使用小参数模型,或者我的提示词写的不好,出现了某些人物“忘记自己是谁”以及“不断重复某句话”的情形。
仔细看看原作者的实验记录,实际上也有很多“重复发言”的问题
不过原作者也有一些看上去不错的实验记录。就我个人而言,对这一策略有2点担忧:一个是如果使用参数量大的模型,是否会因为推理时间过长导致模型发言的间隔过长?另外就是不断调用模型会不会导致耗费的金钱飙升?