第八章 综合实践:构建“谁是卧底”游戏智能体


文档摘要

第八章 综合实践:构建“谁是卧底”游戏智能体 前言 经过前面的学习,我们已经掌握了LangChain、LangGraph 构建智能体运行逻辑,以及智能体的调试与优化方法。本章将通过“谁是卧底”游戏智能体引擎的综合实战,帮大家串联所有知识点,实现从“理论学习”到“工程实践”的落地。 本章核心目标: 理解“谁是卧底”游戏的核心逻辑,拆解智能体引擎的核心模块; 熟练运用LangChain + LangGraph 框架,搭建可运行的游戏智能体; 掌握实战中常见问题的调试方法,能独立完成智能体的基础优化; 完成实战作品提交,掌握项目文档(Readme)的撰写规范。 说明:本章实战提供两种实现路径,适配不同基础的同学——基础薄弱的同学可直接复制本章提供的“案例代码”,完成配置后即可运行;

第八章 综合实践:构建“谁是卧底”游戏智能体

前言

经过前面的学习,我们已经掌握了LangChain、LangGraph 构建智能体运行逻辑,以及智能体的调试与优化方法。本章将通过“谁是卧底”游戏智能体引擎的综合实战,帮大家串联所有知识点,实现从“理论学习”到“工程实践”的落地。

本章核心目标:

  • 理解“谁是卧底”游戏的核心逻辑,拆解智能体引擎的核心模块;
  • 熟练运用LangChain + LangGraph 框架,搭建可运行的游戏智能体;
  • 掌握实战中常见问题的调试方法,能独立完成智能体的基础优化;
  • 完成实战作品提交,掌握项目文档(Readme)的撰写规范。

说明:本章实战提供两种实现路径,适配不同基础的同学——基础薄弱的同学可直接复制本章提供的“案例代码”,完成配置后即可运行;基础较好的同学可结合前面知识,自主设计完成个性化创作。

8.1项目需求拆解:“谁是卧底”游戏智能体引擎

8.1.1 游戏规则简化

为了降低实战难度,我们简化“谁是卧底”游戏规则,核心逻辑如下(所有智能体将遵循此规则运行):

**视角1:上帝视角(默认适配实战开发,用户旁观)**核心设定:用户作为上帝/旁观者,不参与游戏操作,所有游戏参与方均为智能体,全程由智能体自主完成游戏流程,用户仅查看游戏输出结果。

  1. 游戏参与方:4个智能体(1个卧底,3个平民);
  2. 词语分配:平民获得相同的“平民词”,卧底获得与平民词相关但不同的“卧底词”(如平民词“奶茶”,卧底词“果汁”);
  3. 游戏流程:发言阶段:4个智能体依次发言,描述自己拿到的词语(不能直接说词语本身);
  4. 投票阶段:每个智能体根据所有发言,投票选出自己怀疑的“卧底”;
  5. 胜负判断:得票最多的智能体被淘汰,若淘汰的是卧底,平民获胜;若淘汰的是平民,游戏继续(重复发言-投票流程);若剩余1个平民和1个卧底,卧底获胜。

**视角2:玩家视角(可选优化,用户参与)**核心设定:用户作为一名玩家,参与到游戏中,与智能体共同进行“谁是卧底”游戏,用户需自主完成发言、投票操作,智能体作为其他玩家配合完成游戏。

  1. 游戏参与方:1名用户(玩家)+ 3个智能体(共4人,1个卧底,3个平民,用户随机分配角色);
  2. 词语分配:系统随机生成平民词和卧底词,用户将收到自己的角色(平民/卧底)和对应词语(仅自己可见),2个智能体分别分配剩余角色和词语;
  3. 游戏流程:发言阶段:用户与3个智能体依次发言,用户自主输入符合规则的发言(不能直接说词语本身),智能体自动生成发言;
  4. 投票阶段:用户根据所有发言,自主选择怀疑的卧底并投票,3个智能体根据发言自动完成投票;
  5. 胜负判断:与上帝视角一致——得票最多的参与者被淘汰,淘汰卧底则平民获胜,淘汰平民则游戏继续,剩余1人平民1人卧底则卧底获胜(用户若被淘汰,可旁观剩余流程)。

说明:本章基础实战默认适配「上帝视角」,无需修改代码即可运行;基础较好的同学可基于玩家视角优化代码,添加用户输入交互逻辑(如接收用户发言、投票输入)。

8.1.2 智能体引擎核心模块拆解

要实现上述游戏流程,智能体引擎需要包含哪些核心模块?

核心模块:

  1. 词语生成模块:随机生成1组平民词和对应的卧底词(确保相关性,如“手机-电话”“米饭-面条”);
  2. 角色分配模块:将词语分配给3个智能体,随机指定1个为卧底,2个为平民;
  3. 发言生成模块:智能体根据自己的角色(平民/卧底)和拿到的词语,生成符合规则的发言;
  4. 投票模块:智能体根据所有发言,分析并投票选出怀疑的卧底;
  5. 游戏控制模块:串联所有模块,控制游戏流程(发言→投票→胜负判断),记录游戏状态;
  6. 结果展示模块:输出每一轮的发言、投票结果,以及最终的游戏胜负。

8.1.3 API密钥配置

本章实战需要调用LLM(请按照以下步骤配置API密钥:

import os from dotenv import load_dotenv from langchain_openai import ChatOpenAI llm = ChatOpenAI( api_key=os.getenv("API_KEY"), base_url="https://api.deepseek.com", model="deepseek-chat", temperature=0.7, max_tokens=500 )

说明:.env文件的作用是隐藏敏感信息,避免直接将API密钥写在代码中(后续提交作品时,务必确保.env文件不提交,Readme中需说明配置方法)。

8.2 实战开发:逐步构建智能体引擎

本节将分步骤实现所有模块,每一步提供“教学引导+可复制代码+代码说明”,基础薄弱的同学可直接复制代码,替换API密钥后即可运行;基础较好的同学可修改提示词模板、优化投票逻辑等。

8.2.1 定义游戏状态

状态(State)用于记录游戏的所有信息(如角色分配、发言记录、投票记录等),后续所有节点都会读取/修改这个状态。我们使用TypedDict定义状态结构,清晰明了。

class GameState(TypedDict): """ 游戏状态字典,存储整个游戏的所有关键数据 TypedDict:提供类型提示,避免键名错误 """ civilian_word: str # 平民词语 undercover_word: str # 卧底词语 role_assignment: dict # 角色分配:{agent1: ("平民"/"卧底", 词语), ...} speeches: dict # 当前轮发言:{agent1: "发言内容", ...} history_speeches: List[Dict[str, str]] # 历史发言列表:[第1轮发言, 第2轮发言, ...] speech_reasoning: dict # 发言策略理由:{agent1: "理由", ...} votes: dict # 当前轮投票:{agent1: "投给agent2", ...} vote_reasoning: dict # 投票理由:{agent1: "理由", ...} game_status: str # 游戏状态:running(进行中)/end(结束) winner: str # 获胜方:civilian(平民)/undercover(卧底) eliminated: List[str] # 被淘汰的玩家列表 round: int # 当前游戏轮次 def init_game_state() -> GameState: return { "civilian_word": "", "undercover_word": "", "role_assignment": {}, "speeches": {}, "history_speeches": [], "speech_reasoning": {}, "votes": {}, "vote_reasoning": {}, "game_status": "running", "winner": "", "eliminated": [], "round": 1 }

8.2.2 实现核心模块

每个核心模块对应一个“节点函数”,节点函数接收当前游戏状态,执行对应逻辑,返回修改后的游戏状态。我们依次实现6个核心模块,每个模块都提供提示词模板。

8.2.2.1 节点1:词语生成模块

def generate_words(state: GameState) -> GameState: prompt = ChatPromptTemplate.from_messages([ ("system", """你是专业的「谁是卧底」游戏出题人,需生成一组高质量的词语对。 核心要求: 1. 词语类型:日常物品/食品/场景(如:奶茶-果汁、牙刷-牙膏),避免生僻词 2. 语义关系:平民词与卧底词高度相似但核心特征不同,有足够博弈空间 3. 难度适配:适合4人游戏,既不轻易暴露也能通过描述区分 4. 输出格式:必须严格按照 JSON 格式输出,示例:{{"civilian": "奶茶", "undercover": "果汁"}} 禁止输出任何额外文字,只返回JSON字符串!"""), ("user", "生成一组符合要求的谁是卧底词语对") ]) chain = prompt | llm | parser result = chain.invoke({}) try: word_data = json.loads(result.strip()) civilian_word = word_data["civilian"] undercover_word = word_data["undercover"] except (json.JSONDecodeError, KeyError): fallback_pairs = [ ("奶茶", "果汁"), ("牙刷", "牙膏"), ("米饭", "面条"), ("手机", "平板"), ("篮球", "足球"), ("咖啡", "红茶") ] civilian_word, undercover_word = random.choice(fallback_pairs) state["civilian_word"] = civilian_word state["undercover_word"] = undercover_word print(f"\n 词语生成完成:平民词={civilian_word} | 卧底词={undercover_word}") return state

在这个节点中我们增加了容错,如果模型调用失败,则从备用库随机生成

8.2.2.2 节点2:角色分配模块

def assign_roles(state: GameState) -> GameState: agents = ["agent1", "agent2", "agent3", "agent4"] undercover = random.choice(agents) for agent in agents: if agent == undercover: state["role_assignment"][agent] = ("卧底", state["undercover_word"]) else: state["role_assignment"][agent] = ("平民", state["civilian_word"]) print("\n 角色分配完成:") for a, (r, w) in state["role_assignment"].items(): print(f" {a}:{r}(词语:{w})") return state

这里就非常简单了,随机分配角色即可

8.2.2.3 模块3:发言生成模块

def generate_speeches(state: GameState) -> GameState: """ 节点3:生成智能体发言(发言/策略均不截断,仅Prompt引导10-100字) 核心逻辑: 1. 结合历史发言制定本轮发言策略(避免重复/矛盾) 2. Prompt层面引导发言长度10-100字,不做强制截断 3. 不同角色(平民/卧底)采用差异化发言策略 4. 发言和策略理由完全保留原始内容,不做任何截断处理 """ speeches = {} reasoning = {} current_round = state["round"] # 格式化历史发言(多轮记忆核心:让智能体参考前轮发言) history_context = "" if state["history_speeches"]: history_context = "【历史发言记录】\n" for idx, round_speeches in enumerate(state["history_speeches"], 1): history_context += f"第{idx}轮发言:\n" for agent, speech in round_speeches.items(): if agent not in state["eliminated"]: history_context += f"- {agent}:{speech}\n" history_context += "\n" # 强化Prompt字数引导(不做后续截断,全靠LLM遵守) prompt = ChatPromptTemplate.from_messages([ ("system", f"""你是「谁是卧底」游戏的资深玩家,当前是第{current_round}轮发言,需结合历史发言制定策略。 【核心规则】 1. 发言要求: - 字数:必须严格控制在10-100个汉字(不含标点),无需截断,直接生成符合长度的完整内容 - 内容:描述词语特征,但绝对不能直接说出词语;结合历史发言调整策略,避免重复自己/他人的描述 - 风格:自然口语化,句子完整通顺,逻辑清晰 - 完整性:确保发言是完整的句子,语义完整不截断 2. 角色策略: - 平民:描述核心特征,帮助其他平民识别卧底;避免重复前轮发言,找出发言矛盾的玩家 - 卧底:模仿平民的描述风格,模糊核心差异;避免与前轮自己的发言矛盾,同时不暴露身份 3. 输出格式:必须严格按照JSON格式输出,示例: {{{{"speech": "这是一种日常饮用的饮品,有多种口味可选,不同品牌的口感差异不大,平时在家或外出都经常能喝到", "reason": "作为平民,详细描述饮品特征,避免重复前轮发言,帮助其他平民识别卧底"}}}} 禁止输出任何额外文字,只返回JSON字符串! {history_context}"""), ("user", "你的角色是{role},拿到的词语是{word}") ]) chain = prompt | llm | parser print(f"\n 第{current_round}轮发言阶段(建议发言长度:10-100字):") for agent, (role, word) in state["role_assignment"].items(): if agent in state["eliminated"]: continue # 调用LLM生成符合角色策略的发言 output = chain.invoke({"role": role, "word": word}) try: # 解析LLM输出的JSON格式数据 speech_data = json.loads(output.strip()) raw_speech = speech_data["speech"] raw_reason = speech_data["reason"] # 核心修改1:移除发言截断,仅保留长度提示(不修改内容) speech = raw_speech # 长度提示(友好提醒,不强制修改) if len(speech) > 100: print(f"⚠️ {agent}({role})发言超过100字(实际{len(speech)}字),内容完整保留") elif len(speech) < 10: print(f"⚠️ {agent}({role})发言不足10字(实际{len(speech)}字),内容完整保留") # 兜底补充逻辑:仅补充内容,不截断(若仍需补充) if len(speech) < 10: if role == "平民": speech = f"{speech},是日常生活中很常见的物品,使用场景非常广泛,几乎每个人都接触过" else: speech = f"{speech},大家在生活中经常能见到或用到,不同场景下的用法基本一致,不容易区分" print(f" {agent}({role})发言补充后:{speech}(长度{len(speech)}字)") except (json.JSONDecodeError, KeyError): # LLM输出解析失败时的兜底发言(完整内容,不截断) if role == "平民": speech = f"第{current_round}轮发言:这是日常能用到的东西,使用频率很高,不同品牌的款式略有差异,但核心功能是一样的,几乎每个家庭都有这类物品,是生活中不可或缺的常用品" raw_reason = f"平民兜底发言,第{current_round}轮避免重复前轮,完整描述物品核心特征,不做截断处理" else: speech = f"第{current_round}轮发言:这是大家都熟悉的物品,平时使用场景很多,外观和功能都比较相似,很难快速区分不同类型,生活中随处可见,几乎每个人都使用过这类物品" raw_reason = f"卧底兜底发言,第{current_round}轮伪装平民,完整模糊描述特征避免暴露身份,不截断" reason = raw_reason # 保存当前智能体的发言和策略理由(完整内容) speeches[agent] = speech reasoning[agent] = reason # 打印发言结果(清晰展示角色和完整内容) print(f"\n{agent}({role})") print(f" 发言:{speech}") print(f" 策略:{reason}") # 将本轮发言存入历史(完整内容,供下一轮参考) state["history_speeches"].append(speeches.copy()) state["speeches"] = speeches state["speech_reasoning"] = reasoning return state

发言生成的核心是提示词模板,我们明确要求发言的长度、风格,以及平民和卧底的发言差异(平民真实描述,卧底伪装);同时排除已淘汰的智能体,确保多轮游戏的合理性。

8.2.2.4 模块4:投票模块

def vote_undercover(state: GameState) -> GameState: votes = {} reasons = {} current_agents = [a for a in state["role_assignment"] if a not in state["eliminated"]] current_round = state["round"] # 格式化发言上下文 speech_context = f"【第{current_round}轮发言】\n" speech_context += "\n".join([f"{agent}:{speech}" for agent, speech in state["speeches"].items()]) if state["history_speeches"]: speech_context += "\n\n【历史发言参考】\n" for idx, round_speeches in enumerate(state["history_speeches"][:-1], 1): speech_context += f"第{idx}轮:\n" for agent, speech in round_speeches.items(): if agent in current_agents: speech_context += f"- {agent}:{speech}\n" prompt = ChatPromptTemplate.from_messages([ ("system", """你是「谁是卧底」游戏的理性玩家,需基于当前轮+历史发言分析并投票。 【分析规则】 1. 投票依据: - 对比玩家当前轮和历史发言,找出矛盾/异常的描述(卧底常出现前后矛盾) - 平民:重点关注发言前后不一致、描述偏离词语特征的玩家 - 卧底:找出看起来像平民的玩家投票,避免自己被怀疑,保持投票理由连贯 2. 输出格式:必须严格按照JSON格式输出,示例: {{{{"vote": "agent2", "reason": "agent2本轮和上轮发言矛盾,描述不符合平民词特征"}}}} 禁止输出任何额外文字,只返回JSON字符串! {speech_context}"""), ("user", """你的角色:{role} 你的词语:{word} 请选择你要投票的玩家并说明理由(理由控制在50字内)""") ]) chain = prompt | llm | parser print(f"\n 第{current_round}轮投票阶段:") for agent, (role, word) in state["role_assignment"].items(): if agent in state["eliminated"]: continue output = chain.invoke({ "role": role, "word": word, "speech_context": speech_context }) try: vote_data = json.loads(output.strip()) vote = vote_data["vote"].strip() raw_reason = vote_data["reason"] reason = raw_reason except (json.JSONDecodeError, KeyError): vote = random.choice([a for a in current_agents if a != agent]) reason = textwrap.shorten( f"第{current_round}轮无有效分析,基于随机策略投票", width=50 ) # 校验投票有效性 if vote == agent or vote not in current_agents: vote = random.choice([a for a in current_agents if a != agent]) votes[agent] = vote reasons[agent] = reason print(f"\n{agent}({role})") print(f" 投票给:{vote}") print(f" 理由:{reason}") state["votes"] = votes state["vote_reasoning"] = reasons return state

投票模块是核心难点,我们添加了多重兜底逻辑——避免投自己、避免投淘汰的玩家、解析失败时随机投票,确保程序稳定运行;同时引导LLM根据发言分析投票,体现智能体的“决策”能力。

8.2.2.5 模块5:胜负判断模块

def judge_result(state: GameState) -> GameState: vote_count = {} for v in state["votes"].values(): vote_count[v] = vote_count.get(v, 0) + 1 max_vote = max(vote_count.values()) eliminated = random.choice([a for a, c in vote_count.items() if c == max_vote]) state["eliminated"].append(eliminated) role = state["role_assignment"][eliminated][0] current_round = state["round"] print(f"\n❌ 第{current_round}轮淘汰结果:{eliminated}({role})") remaining = [a for a in state["role_assignment"] if a not in state["eliminated"]] civ = sum(1 for a in remaining if state["role_assignment"][a][0] == "平民") uc = sum(1 for a in remaining if state["role_assignment"][a][0] == "卧底") if role == "卧底": state["game_status"] = "end" state["winner"] = "civilian" print(" 平民胜利!") elif civ == 1 and uc == 1: state["game_status"] = "end" state["winner"] = "undercover" print(" 卧底胜利!") else: state["game_status"] = "running" state["round"] += 1 print(f"➡ 游戏继续,进入第{state['round']}轮") return state

8.2.2.6 模块6:结果展示模块

def show_final_result(state: GameState) -> GameState: print("\n" + "="*50) print(" 游戏结束 · 总结") print(f"胜利方:{'平民' if state['winner'] == 'civilian' else '卧底'}") print(f"平民词:{state['civilian_word']} | 卧底词:{state['undercover_word']}") print(f"总轮次:{state['round']}") print(f"淘汰顺序:{state['eliminated']}") print("="*50) return state

8.2.3 构建LangGraph图结构

def build_game_graph(): graph = StateGraph(GameState) graph.add_node("generate_words", generate_words) graph.add_node("assign_roles", assign_roles) graph.add_node("generate_speeches", generate_speeches) graph.add_node("vote_undercover", vote_undercover) graph.add_node("judge_result", judge_result) graph.add_node("show_final_result", show_final_result) graph.set_entry_point("generate_words") graph.add_edge("generate_words", "assign_roles") graph.add_edge("assign_roles", "generate_speeches") graph.add_edge("generate_speeches", "vote_undercover") graph.add_edge("vote_undercover", "judge_result") def route(state: GameState): return "generate_speeches" if state["game_status"] == "running" else "show_final_result" graph.add_conditional_edges("judge_result", route) graph.add_edge("show_final_result", END) return graph

图结构的核心是“节点+边”,我们通过add_node添加所有模块,通过add_edge定义固定跳转,通过add_conditional_edges定义条件跳转(游戏继续/结束),set_entry_point定义游戏入口,完美串联整个游戏流程。

8.3 完整运行代码

以下是完整的可运行代码,基础薄弱的同学可直接复制到Python文件中(命名为who_is_undercover.py),确保.env文件配置正确(API密钥),运行后即可看到完整的游戏流程。

# ================== 导入核心依赖 ================== import random import os import json import textwrap from typing import TypedDict, List, Dict from dotenv import load_dotenv from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from langgraph.graph import StateGraph, END # ================== 初始化大模型 ================== load_dotenv() llm = ChatOpenAI( api_key=os.getenv("API_KEY"), base_url="https://api.deepseek.com", model="deepseek-chat", temperature=0.7, max_tokens=500 ) parser = StrOutputParser() # ================== 1. 定义游戏状态 ================== class GameState(TypedDict): """ 游戏状态字典,存储整个游戏的所有关键数据 TypedDict:提供类型提示,避免键名错误 """ civilian_word: str # 平民词语 undercover_word: str # 卧底词语 role_assignment: dict # 角色分配:{agent1: ("平民"/"卧底", 词语), ...} speeches: dict # 当前轮发言:{agent1: "发言内容", ...} history_speeches: List[Dict[str, str]] # 历史发言列表:[第1轮发言, 第2轮发言, ...] speech_reasoning: dict # 发言策略理由:{agent1: "理由", ...} votes: dict # 当前轮投票:{agent1: "投给agent2", ...} vote_reasoning: dict # 投票理由:{agent1: "理由", ...} game_status: str # 游戏状态:running(进行中)/end(结束) winner: str # 获胜方:civilian(平民)/undercover(卧底) eliminated: List[str] # 被淘汰的玩家列表 round: int # 当前游戏轮次 def init_game_state() -> GameState: return { "civilian_word": "", "undercover_word": "", "role_assignment": {}, "speeches": {}, "history_speeches": [], "speech_reasoning": {}, "votes": {}, "vote_reasoning": {}, "game_status": "running", "winner": "", "eliminated": [], "round": 1 } # ================== 2. 节点函数 ================== def generate_words(state: GameState) -> GameState: prompt = ChatPromptTemplate.from_messages([ ("system", """你是专业的「谁是卧底」游戏出题人,需生成一组高质量的词语对。 核心要求: 1. 词语类型:日常物品/食品/场景(如:奶茶-果汁、牙刷-牙膏),避免生僻词 2. 语义关系:平民词与卧底词高度相似但核心特征不同,有足够博弈空间 3. 难度适配:适合4人游戏,既不轻易暴露也能通过描述区分 4. 输出格式:必须严格按照 JSON 格式输出,示例:{{"civilian": "奶茶", "undercover": "果汁"}} 禁止输出任何额外文字,只返回JSON字符串!"""), ("user", "生成一组符合要求的谁是卧底词语对") ]) chain = prompt | llm | parser result = chain.invoke({}) try: word_data = json.loads(result.strip()) civilian_word = word_data["civilian"] undercover_word = word_data["undercover"] except (json.JSONDecodeError, KeyError): fallback_pairs = [ ("奶茶", "果汁"), ("牙刷", "牙膏"), ("米饭", "面条"), ("手机", "平板"), ("篮球", "足球"), ("咖啡", "红茶") ] civilian_word, undercover_word = random.choice(fallback_pairs) state["civilian_word"] = civilian_word state["undercover_word"] = undercover_word print(f"\n 词语生成完成:平民词={civilian_word} | 卧底词={undercover_word}") return state # ---- 节点2:分配角色 ---- def assign_roles(state: GameState) -> GameState: agents = ["agent1", "agent2", "agent3", "agent4"] undercover = random.choice(agents) for agent in agents: if agent == undercover: state["role_assignment"][agent] = ("卧底", state["undercover_word"]) else: state["role_assignment"][agent] = ("平民", state["civilian_word"]) print("\n 角色分配完成:") for a, (r, w) in state["role_assignment"].items(): print(f" {a}:{r}(词语:{w})") return state # ---- 节点3:发言---- def generate_speeches(state: GameState) -> GameState: """ 节点3:生成智能体发言(发言/策略均不截断,仅Prompt引导10-100字) 核心逻辑: 1. 结合历史发言制定本轮发言策略(避免重复/矛盾) 2. Prompt层面引导发言长度10-100字,不做强制截断 3. 不同角色(平民/卧底)采用差异化发言策略 4. 发言和策略理由完全保留原始内容,不做任何截断处理 """ speeches = {} reasoning = {} current_round = state["round"] # 格式化历史发言(多轮记忆核心:让智能体参考前轮发言) history_context = "" if state["history_speeches"]: history_context = "【历史发言记录】\n" for idx, round_speeches in enumerate(state["history_speeches"], 1): history_context += f"第{idx}轮发言:\n" for agent, speech in round_speeches.items(): if agent not in state["eliminated"]: history_context += f"- {agent}:{speech}\n" history_context += "\n" # 强化Prompt字数引导(不做后续截断,全靠LLM遵守) prompt = ChatPromptTemplate.from_messages([ ("system", f"""你是「谁是卧底」游戏的资深玩家,当前是第{current_round}轮发言,需结合历史发言制定策略。 【核心规则】 1. 发言要求: - 字数:必须严格控制在10-100个汉字(不含标点),无需截断,直接生成符合长度的完整内容 - 内容:描述词语特征,但绝对不能直接说出词语;结合历史发言调整策略,避免重复自己/他人的描述 - 风格:自然口语化,句子完整通顺,逻辑清晰 - 完整性:确保发言是完整的句子,语义完整不截断 2. 角色策略: - 平民:描述核心特征,帮助其他平民识别卧底;避免重复前轮发言,找出发言矛盾的玩家 - 卧底:模仿平民的描述风格,模糊核心差异;避免与前轮自己的发言矛盾,同时不暴露身份 3. 输出格式:必须严格按照JSON格式输出,示例: {{{{"speech": "这是一种日常饮用的饮品,有多种口味可选,不同品牌的口感差异不大,平时在家或外出都经常能喝到", "reason": "作为平民,详细描述饮品特征,避免重复前轮发言,帮助其他平民识别卧底"}}}} 禁止输出任何额外文字,只返回JSON字符串! {history_context}"""), ("user", "你的角色是{role},拿到的词语是{word}") ]) chain = prompt | llm | parser print(f"\n 第{current_round}轮发言阶段(建议发言长度:10-100字):") for agent, (role, word) in state["role_assignment"].items(): if agent in state["eliminated"]: continue # 调用LLM生成符合角色策略的发言 output = chain.invoke({"role": role, "word": word}) try: # 解析LLM输出的JSON格式数据 speech_data = json.loads(output.strip()) raw_speech = speech_data["speech"] raw_reason = speech_data["reason"] # 核心修改1:移除发言截断,仅保留长度提示(不修改内容) speech = raw_speech # 长度提示(友好提醒,不强制修改) if len(speech) > 100: print(f"⚠️ {agent}({role})发言超过100字(实际{len(speech)}字),内容完整保留") elif len(speech) < 10: print(f"⚠️ {agent}({role})发言不足10字(实际{len(speech)}字),内容完整保留") # 兜底补充逻辑:仅补充内容,不截断(若仍需补充) if len(speech) < 10: if role == "平民": speech = f"{speech},是日常生活中很常见的物品,使用场景非常广泛,几乎每个人都接触过" else: speech = f"{speech},大家在生活中经常能见到或用到,不同场景下的用法基本一致,不容易区分" print(f" {agent}({role})发言补充后:{speech}(长度{len(speech)}字)") except (json.JSONDecodeError, KeyError): # LLM输出解析失败时的兜底发言(完整内容,不截断) if role == "平民": speech = f"第{current_round}轮发言:这是日常能用到的东西,使用频率很高,不同品牌的款式略有差异,但核心功能是一样的,几乎每个家庭都有这类物品,是生活中不可或缺的常用品" raw_reason = f"平民兜底发言,第{current_round}轮避免重复前轮,完整描述物品核心特征,不做截断处理" else: speech = f"第{current_round}轮发言:这是大家都熟悉的物品,平时使用场景很多,外观和功能都比较相似,很难快速区分不同类型,生活中随处可见,几乎每个人都使用过这类物品" raw_reason = f"卧底兜底发言,第{current_round}轮伪装平民,完整模糊描述特征避免暴露身份,不截断" reason = raw_reason # 保存当前智能体的发言和策略理由(完整内容) speeches[agent] = speech reasoning[agent] = reason # 打印发言结果(清晰展示角色和完整内容) print(f"\n{agent}({role})") print(f" 发言:{speech}") print(f" 策略:{reason}") # 将本轮发言存入历史(完整内容,供下一轮参考) state["history_speeches"].append(speeches.copy()) state["speeches"] = speeches state["speech_reasoning"] = reasoning return state def vote_undercover(state: GameState) -> GameState: votes = {} reasons = {} current_agents = [a for a in state["role_assignment"] if a not in state["eliminated"]] current_round = state["round"] # 格式化发言上下文 speech_context = f"【第{current_round}轮发言】\n" speech_context += "\n".join([f"{agent}:{speech}" for agent, speech in state["speeches"].items()]) if state["history_speeches"]: speech_context += "\n\n【历史发言参考】\n" for idx, round_speeches in enumerate(state["history_speeches"][:-1], 1): speech_context += f"第{idx}轮:\n" for agent, speech in round_speeches.items(): if agent in current_agents: speech_context += f"- {agent}:{speech}\n" prompt = ChatPromptTemplate.from_messages([ ("system", """你是「谁是卧底」游戏的理性玩家,需基于当前轮+历史发言分析并投票。 【分析规则】 1. 投票依据: - 对比玩家当前轮和历史发言,找出矛盾/异常的描述(卧底常出现前后矛盾) - 平民:重点关注发言前后不一致、描述偏离词语特征的玩家 - 卧底:找出看起来像平民的玩家投票,避免自己被怀疑,保持投票理由连贯 2. 输出格式:必须严格按照JSON格式输出,示例: {{{{"vote": "agent2", "reason": "agent2本轮和上轮发言矛盾,描述不符合平民词特征"}}}} 禁止输出任何额外文字,只返回JSON字符串! {speech_context}"""), ("user", """你的角色:{role} 你的词语:{word} 请选择你要投票的玩家并说明理由(理由控制在50字内)""") ]) chain = prompt | llm | parser print(f"\n 第{current_round}轮投票阶段:") for agent, (role, word) in state["role_assignment"].items(): if agent in state["eliminated"]: continue output = chain.invoke({ "role": role, "word": word, "speech_context": speech_context }) try: vote_data = json.loads(output.strip()) vote = vote_data["vote"].strip() raw_reason = vote_data["reason"] reason = raw_reason except (json.JSONDecodeError, KeyError): vote = random.choice([a for a in current_agents if a != agent]) reason = textwrap.shorten( f"第{current_round}轮无有效分析,基于随机策略投票", width=50 ) # 校验投票有效性 if vote == agent or vote not in current_agents: vote = random.choice([a for a in current_agents if a != agent]) votes[agent] = vote reasons[agent] = reason print(f"\n{agent}({role})") print(f" 投票给:{vote}") print(f" 理由:{reason}") state["votes"] = votes state["vote_reasoning"] = reasons return state # ---- 节点5:裁决 ---- def judge_result(state: GameState) -> GameState: vote_count = {} for v in state["votes"].values(): vote_count[v] = vote_count.get(v, 0) + 1 max_vote = max(vote_count.values()) eliminated = random.choice([a for a, c in vote_count.items() if c == max_vote]) state["eliminated"].append(eliminated) role = state["role_assignment"][eliminated][0] current_round = state["round"] print(f"\n❌ 第{current_round}轮淘汰结果:{eliminated}({role})") remaining = [a for a in state["role_assignment"] if a not in state["eliminated"]] civ = sum(1 for a in remaining if state["role_assignment"][a][0] == "平民") uc = sum(1 for a in remaining if state["role_assignment"][a][0] == "卧底") if role == "卧底": state["game_status"] = "end" state["winner"] = "civilian" print(" 平民胜利!") elif civ == 1 and uc == 1: state["game_status"] = "end" state["winner"] = "undercover" print(" 卧底胜利!") else: state["game_status"] = "running" state["round"] += 1 print(f"➡ 游戏继续,进入第{state['round']}轮") return state # ---- 节点6:总结 ---- def show_final_result(state: GameState) -> GameState: print("\n" + "="*50) print(" 游戏结束 · 总结") print(f"胜利方:{'平民' if state['winner'] == 'civilian' else '卧底'}") print(f"平民词:{state['civilian_word']} | 卧底词:{state['undercover_word']}") print(f"总轮次:{state['round']}") print(f"淘汰顺序:{state['eliminated']}") print("="*50) return state # ================== 3. 构建 LangGraph ================== def build_game_graph(): graph = StateGraph(GameState) graph.add_node("generate_words", generate_words) graph.add_node("assign_roles", assign_roles) graph.add_node("generate_speeches", generate_speeches) graph.add_node("vote_undercover", vote_undercover) graph.add_node("judge_result", judge_result) graph.add_node("show_final_result", show_final_result) graph.set_entry_point("generate_words") graph.add_edge("generate_words", "assign_roles") graph.add_edge("assign_roles", "generate_speeches") graph.add_edge("generate_speeches", "vote_undercover") graph.add_edge("vote_undercover", "judge_result") def route(state: GameState): return "generate_speeches" if state["game_status"] == "running" else "show_final_result" graph.add_conditional_edges("judge_result", route) graph.add_edge("show_final_result", END) return graph # ================== 4. 入口 ================== if __name__ == "__main__": game_graph = build_game_graph() game = game_graph.compile() print("="*50) print(" 谁是卧底 · 多智能体多轮策略版 启动") print("="*50) game.invoke(init_game_state())

运行结果

词语生成完成:平民词=牙刷 | 卧底词=牙膏 角色分配完成: agent1:平民(词语:牙刷) agent2:平民(词语:牙刷) agent3:平民(词语:牙刷) agent4:卧底(词语:牙膏) 第1轮发言阶段(建议发言长度:10-100字): agent1(平民) 发言:这是一种日常清洁用品,通常早晚使用,有多种刷毛软硬可选,能有效保持口腔卫生。 策略:作为平民,直接描述牙刷的核心功能和使用场景,避免提及品牌或具体形状,帮助其他平民识别卧底。 agent2(平民) 发言:这是一种日常清洁工具,通常早晚各用一次,能有效保持口腔卫生,刷毛有软硬之分,需要定期更换 策略:作为平民,描述牙刷的核心功能和使用场景,避免直接说出词语,帮助其他平民识别卧底 agent3(平民) 发言:这是一种日常清洁用品,通常早晚使用,有不同的刷毛软硬度,能有效清洁牙齿和口腔卫生。 策略:作为平民,描述牙刷的核心特征如清洁用途、使用频率和刷毛特点,避免直接说出词语,帮助其他平民识别卧底。 agent4(卧底) 发言:这是一种日常清洁用品,每天早晚都会用到,有不同的味道和功效,能保持口腔清新健康 策略:作为卧底,模仿平民描述日常清洁用品的特征,避免直接说出词语,同时保持描述自然,不暴露身份 第1轮投票阶段: agent1(平民) 投票给:agent4 理由:agent4描述“不同的味道和功效”更接近牙膏特征,与牙刷的核心功能(刷毛、清洁牙齿)存在偏差。 agent2(平民) 投票给:agent4 理由:agent4提到'不同的味道和功效',这与牙刷的典型特征不符,更像是在描述牙膏,发言可疑。 agent3(平民) 投票给:agent4 理由:agent4描述'不同的味道和功效'不符合牙刷的核心特征,更像是牙膏的描述,偏离平民词。 agent4(卧底) 投票给:agent1 理由:agent4描述“不同的味道和功效”更贴近牙膏,与平民词牙刷的清洁工具特征不符,易暴露平民身份。 ❌ 第1轮淘汰结果:agent4(卧底) 平民胜利! ================================================== 游戏结束 · 总结 胜利方:平民 平民词:牙刷 | 卧底词:牙膏 总轮次:1 淘汰顺序:['agent4'] ================================================== (base) PS C:\Users\xiong\Desktop\iii>

运行代码包含了所有模块,无需修改,只需确保.env文件中的API密钥正确,运行后即可看到完整的游戏流程(词语生成→角色分配→发言→投票→淘汰→结果展示)。基础薄弱的同学可直接使用此代码完成实战。

作品提交要求

本章需完成实战作品,并按以下要求提交

1.提交路径

在本项目easy-langent中的project文件夹提交自己的综合实践作品,具体样例可参考NovelGenerateDemo

提交时需在 project 文件夹内创建个人专属子文件夹,文件夹命名格式使用驼峰命名的规则进行命名,例如WhoIsTheSpy,提交使用GitHub PR(Pull Request)进行提交即可。

2 提交文件清单

提交文件需齐全、命名规范,具体清单如下:

  1. 核心代码文件(必选):确保代码可直接运行(替换API密钥后无报错);
  2. 项目说明文档(必选):命名为“Readme.md”,为Markdown格式,核心内容缺一不可,具体包含: 项目简介、核心功能、Python版本、依赖包安装命令,以及代码运行步骤(确保他人可顺利使用);

重要提示:禁止提交.env文件,避免API密钥泄露,无需在文件夹中放置.env文件。


作者与出处
原作者: Datawhale
来源:Datawhale
许可证:CC BY-NC-SA 4.0
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: Datawhale 转发
评论区 (0)
U