第八章 综合实践:构建“谁是卧底”游戏智能体
前言
经过前面的学习,我们已经掌握了LangChain、LangGraph 构建智能体运行逻辑,以及智能体的调试与优化方法。本章将通过“谁是卧底”游戏智能体引擎的综合实战,帮大家串联所有知识点,实现从“理论学习”到“工程实践”的落地。
本章核心目标:
- 理解“谁是卧底”游戏的核心逻辑,拆解智能体引擎的核心模块;
- 熟练运用LangChain + LangGraph 框架,搭建可运行的游戏智能体;
- 掌握实战中常见问题的调试方法,能独立完成智能体的基础优化;
- 完成实战作品提交,掌握项目文档(Readme)的撰写规范。
说明:本章实战提供两种实现路径,适配不同基础的同学——基础薄弱的同学可直接复制本章提供的“案例代码”,完成配置后即可运行;基础较好的同学可结合前面知识,自主设计完成个性化创作。
8.1项目需求拆解:“谁是卧底”游戏智能体引擎
8.1.1 游戏规则简化
为了降低实战难度,我们简化“谁是卧底”游戏规则,核心逻辑如下(所有智能体将遵循此规则运行):
**视角1:上帝视角(默认适配实战开发,用户旁观)**核心设定:用户作为上帝/旁观者,不参与游戏操作,所有游戏参与方均为智能体,全程由智能体自主完成游戏流程,用户仅查看游戏输出结果。
- 游戏参与方:4个智能体(1个卧底,3个平民);
- 词语分配:平民获得相同的“平民词”,卧底获得与平民词相关但不同的“卧底词”(如平民词“奶茶”,卧底词“果汁”);
- 游戏流程:发言阶段:4个智能体依次发言,描述自己拿到的词语(不能直接说词语本身);
- 投票阶段:每个智能体根据所有发言,投票选出自己怀疑的“卧底”;
- 胜负判断:得票最多的智能体被淘汰,若淘汰的是卧底,平民获胜;若淘汰的是平民,游戏继续(重复发言-投票流程);若剩余1个平民和1个卧底,卧底获胜。
**视角2:玩家视角(可选优化,用户参与)**核心设定:用户作为一名玩家,参与到游戏中,与智能体共同进行“谁是卧底”游戏,用户需自主完成发言、投票操作,智能体作为其他玩家配合完成游戏。
- 游戏参与方:1名用户(玩家)+ 3个智能体(共4人,1个卧底,3个平民,用户随机分配角色);
- 词语分配:系统随机生成平民词和卧底词,用户将收到自己的角色(平民/卧底)和对应词语(仅自己可见),2个智能体分别分配剩余角色和词语;
- 游戏流程:发言阶段:用户与3个智能体依次发言,用户自主输入符合规则的发言(不能直接说词语本身),智能体自动生成发言;
- 投票阶段:用户根据所有发言,自主选择怀疑的卧底并投票,3个智能体根据发言自动完成投票;
- 胜负判断:与上帝视角一致——得票最多的参与者被淘汰,淘汰卧底则平民获胜,淘汰平民则游戏继续,剩余1人平民1人卧底则卧底获胜(用户若被淘汰,可旁观剩余流程)。
说明:本章基础实战默认适配「上帝视角」,无需修改代码即可运行;基础较好的同学可基于玩家视角优化代码,添加用户输入交互逻辑(如接收用户发言、投票输入)。
8.1.2 智能体引擎核心模块拆解
要实现上述游戏流程,智能体引擎需要包含哪些核心模块?
核心模块:
- 词语生成模块:随机生成1组平民词和对应的卧底词(确保相关性,如“手机-电话”“米饭-面条”);
- 角色分配模块:将词语分配给3个智能体,随机指定1个为卧底,2个为平民;
- 发言生成模块:智能体根据自己的角色(平民/卧底)和拿到的词语,生成符合规则的发言;
- 投票模块:智能体根据所有发言,分析并投票选出怀疑的卧底;
- 游戏控制模块:串联所有模块,控制游戏流程(发言→投票→胜负判断),记录游戏状态;
- 结果展示模块:输出每一轮的发言、投票结果,以及最终的游戏胜负。
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 state8.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 state8.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 提交文件清单
提交文件需齐全、命名规范,具体清单如下:
- 核心代码文件(必选):确保代码可直接运行(替换API密钥后无报错);
- 项目说明文档(必选):命名为“Readme.md”,为Markdown格式,核心内容缺一不可,具体包含: 项目简介、核心功能、Python版本、依赖包安装命令,以及代码运行步骤(确保他人可顺利使用);
重要提示:禁止提交.env文件,避免API密钥泄露,无需在文件夹中放置.env文件。