Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
332 lines
17 KiB
Python
332 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""玩家 Agent:每个玩家 = 一个独立的 LLM Agent,拥有**严格隔离的私有上下文**。
|
||
|
||
信息隔离的实现要点:
|
||
- 每个 PlayerAgent 只维护自己的 `memory`(一串它「观察到 / 被告知」的事件)。
|
||
- 法官(judge.py)决定把哪条信息推给哪个 Agent 的 memory——狼人才会收到「队友
|
||
身份」,预言家才会收到「查验结果」,公开发言才会推给所有人。
|
||
- Agent 每次思考(发言 / 投票 / 用技能)时,只能看到自己 memory 里的内容,
|
||
因此不可能「偷看」到本不该看到的信息。这就是信息权限控制的落点。
|
||
|
||
离线(--offline / --mock)策略:当没有 OpenAI Key、或想零成本可复现地跑完整一局时,
|
||
Agent 用一套**规则驱动**的决策代替 LLM。关键在于:离线策略同样**只读自己的 memory**
|
||
(不碰其他 Agent 的私有上下文),因此信息权限控制这一教学要点在离线模式下依然成立、
|
||
依然可被审计校验。
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
from typing import List, Optional
|
||
|
||
from .roles import Role, ROLE_STRATEGY, faction_of
|
||
|
||
|
||
# 全局唯一的 LLM 客户端。模型默认当前便宜旗舰 gpt-5.6-luna。
|
||
# 通用回退:优先 OPENAI_API_KEY 直连 OpenAI;没有则用 OPENROUTER_API_KEY 走 OpenRouter。
|
||
_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.6-luna")
|
||
_client = None
|
||
|
||
|
||
def _to_openrouter_model(model: str) -> str:
|
||
"""把模型名映射到 OpenRouter 命名空间(用于无 OPENAI_API_KEY 的回退路径)。"""
|
||
if "/" in model:
|
||
return model # 已是 OpenRouter 命名空间,原样使用
|
||
if model.startswith("gpt-"):
|
||
return "openai/" + model # gpt-* -> openai/gpt-*
|
||
if model.startswith("claude-"):
|
||
return "anthropic/claude-opus-4.8"
|
||
return "openai/gpt-5.6-luna" # 兜底:当前便宜旗舰
|
||
|
||
|
||
def _safe_create(client, **kwargs):
|
||
"""调用 Chat Completions;对推理型模型(如 gpt-5.x)的参数限制做自动降级重试:
|
||
- 不支持 max_tokens 时改用 max_completion_tokens;
|
||
- 不支持非默认 temperature 时移除该参数(回退到模型默认 1)。
|
||
这样同一份代码既能跑传统对话模型(接受 temperature=0.8),也能跑推理模型。"""
|
||
for _ in range(3):
|
||
try:
|
||
return client.chat.completions.create(**kwargs)
|
||
except Exception as e:
|
||
msg = str(e)
|
||
if "max_completion_tokens" in msg and "max_tokens" in kwargs:
|
||
kwargs["max_completion_tokens"] = kwargs.pop("max_tokens")
|
||
continue
|
||
if "temperature" in msg and "temperature" in kwargs:
|
||
kwargs.pop("temperature", None)
|
||
continue
|
||
raise
|
||
return client.chat.completions.create(**kwargs)
|
||
|
||
|
||
def get_client():
|
||
"""返回全局共享的 LLM 客户端(懒加载,进程内单例)。
|
||
|
||
仅在线模式(真实调用 LLM)才会用到;离线模式不导入 openai、不构造客户端。
|
||
1) 有 ARK/Moonshot key -> 使用其 OpenAI-compatible real endpoint;
|
||
2) 否则直连 OpenAI;3) 最后才回退 OpenRouter。
|
||
"""
|
||
global _client, _MODEL
|
||
if _client is None:
|
||
from openai import OpenAI # 懒导入:离线模式无需安装 openai
|
||
client_options = {
|
||
"timeout": float(os.getenv("WEREWOLF_LLM_TIMEOUT", "45")),
|
||
"max_retries": int(os.getenv("WEREWOLF_LLM_RETRIES", "1")),
|
||
}
|
||
if os.environ.get("ARK_API_KEY"):
|
||
_MODEL = os.getenv("ARK_MODEL", "doubao-seed-1-6-250615")
|
||
_client = OpenAI(api_key=os.environ["ARK_API_KEY"],
|
||
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
||
**client_options)
|
||
elif os.environ.get("MOONSHOT_API_KEY"):
|
||
_MODEL = os.getenv("MOONSHOT_MODEL", "kimi-k3")
|
||
_client = OpenAI(api_key=os.environ["MOONSHOT_API_KEY"],
|
||
base_url="https://api.moonshot.cn/v1", **client_options)
|
||
elif os.environ.get("OPENAI_API_KEY"):
|
||
_client = OpenAI(**client_options) # 自动读取 OPENAI_API_KEY
|
||
elif os.environ.get("OPENROUTER_API_KEY"):
|
||
_MODEL = _to_openrouter_model(_MODEL)
|
||
_client = OpenAI(
|
||
api_key=os.environ["OPENROUTER_API_KEY"],
|
||
base_url="https://openrouter.ai/api/v1",
|
||
**client_options,
|
||
)
|
||
else:
|
||
raise RuntimeError(
|
||
"未设置 ARK/MOONSHOT/OPENAI/OPENROUTER 任一文本模型 Key,请参考 env.example,"
|
||
"或改用离线模式:python demo.py --offline"
|
||
)
|
||
return _client
|
||
|
||
|
||
class PlayerAgent:
|
||
"""一个玩家 Agent,封装其身份、私有上下文与决策(LLM 或离线规则)。"""
|
||
|
||
def __init__(self, name: str, role: Role, offline: bool = False, rng=None):
|
||
self.name = name # 玩家名,如 "P3"
|
||
self.role = role # 真实身份(只有本人和法官知道)
|
||
self.faction = faction_of(role)
|
||
self.alive = True
|
||
self.offline = offline # True 时用规则策略代替 LLM(零成本、可复现)
|
||
# 离线策略的私有随机源(按玩家名种子化,保证可复现且各玩家独立)
|
||
import random as _random
|
||
self._rng = rng or _random.Random(hash(name) & 0xFFFF)
|
||
# 私有上下文:这个 Agent「看得到」的全部信息。别的 Agent 无法访问。
|
||
self.memory: List[str] = []
|
||
|
||
# ---- 上下文注入:只有法官会调用,用来把信息投递进这个 Agent 的私有上下文 ----
|
||
def observe(self, event: str):
|
||
"""把一条信息写入本 Agent 的私有上下文。"""
|
||
self.memory.append(event)
|
||
|
||
# ---- system prompt:角色设定 + 策略。狼人的队友身份不写在这里,而是由法官
|
||
# 在游戏开始时通过 observe() 投递,以便审计能记录「谁看到了队友身份」。 ----
|
||
def _system_prompt(self, players: List[str]) -> str:
|
||
return (
|
||
f"你正在玩一局狼人杀。你是玩家 {self.name}。\n"
|
||
f"你的真实身份是【{self.role.value}】,属于【{self.faction.value}】。\n"
|
||
f"本局玩家共 {len(players)} 人:{'、'.join(players)}。\n\n"
|
||
f"{ROLE_STRATEGY[self.role]}\n\n"
|
||
"重要:只能依据你已知的信息推理,不要臆造你无从得知的身份。发言要像真人,"
|
||
"简洁自然,有理有据。"
|
||
)
|
||
|
||
def _context_block(self) -> str:
|
||
"""把私有上下文拼成给 LLM 的一段文字。"""
|
||
if not self.memory:
|
||
return "(暂无信息)"
|
||
return "\n".join(f"- {m}" for m in self.memory)
|
||
|
||
def _chat(self, instruction: str, players: List[str], max_tokens: int,
|
||
json_mode: bool = False) -> str:
|
||
"""组装 system + user 消息并调用 LLM;user 消息里只拼接本 Agent 自己的
|
||
私有上下文(`_context_block`),绝不包含其他玩家的私密信息。"""
|
||
messages = [
|
||
{"role": "system", "content": self._system_prompt(players)},
|
||
{"role": "user", "content":
|
||
f"【你目前掌握的信息(仅你可见)】\n{self._context_block()}\n\n"
|
||
f"【当前任务】\n{instruction}"},
|
||
]
|
||
# 给推理型模型(如 gpt-5.6 系列)留足输出预算:其内部推理 token 也计入
|
||
# max_tokens,预算过小会导致 content 被截断为空。设一个下限兜底。
|
||
# Resolve the provider before reading _MODEL: get_client() may switch the
|
||
# model id from the OpenAI default to an ARK/Moonshot endpoint id.
|
||
client = get_client()
|
||
kwargs = dict(model=_MODEL, messages=messages, temperature=0.8,
|
||
max_tokens=max(max_tokens, 512))
|
||
if json_mode:
|
||
kwargs["response_format"] = {"type": "json_object"}
|
||
resp = _safe_create(client, **kwargs)
|
||
content = (resp.choices[0].message.content or "").strip()
|
||
if not content:
|
||
# Some reasoning models can spend the entire small action budget on
|
||
# hidden reasoning and return no visible speech/JSON. Retry once with
|
||
# a larger bounded budget; an empty second response remains a hard
|
||
# failure instead of becoming silent speech or a random action.
|
||
retry_kwargs = dict(kwargs)
|
||
budget_key = (
|
||
"max_completion_tokens"
|
||
if "max_completion_tokens" in retry_kwargs
|
||
else "max_tokens"
|
||
)
|
||
retry_kwargs[budget_key] = max(int(retry_kwargs.get(budget_key, 0)) * 4, 2048)
|
||
resp = _safe_create(client, **retry_kwargs)
|
||
content = (resp.choices[0].message.content or "").strip()
|
||
if not content:
|
||
raise RuntimeError("LLM returned empty visible content after bounded retry")
|
||
return content
|
||
|
||
# ---------- 三种对外能力:发言 / 决策(选目标)/ 投票 ----------
|
||
|
||
def speak(self, players: List[str]) -> str:
|
||
"""白天公开发言。返回一段发言文本(公开信息)。"""
|
||
if self.offline:
|
||
return self._offline_speak(candidates=[p for p in players if p != self.name])
|
||
instruction = (
|
||
"现在轮到你在白天公开发言。请结合你掌握的信息,发表一段简短的发言"
|
||
"(2~4 句话,60 字以内)。符合你的身份与策略。直接输出发言内容,不要加引号。"
|
||
)
|
||
return self._chat(instruction, players, max_tokens=180)
|
||
|
||
def choose_target(self, prompt: str, candidates: List[str],
|
||
players: List[str], allow_none: bool = False) -> Optional[str]:
|
||
"""让 Agent 从候选人中选一个目标(夜间刀人 / 查验 / 用毒 / 救人判断等)。
|
||
|
||
用 JSON 模式返回,鲁棒地解析出目标玩家名。
|
||
"""
|
||
if self.offline:
|
||
return self._offline_choose_target(candidates, allow_none)
|
||
opt = ",也可以选择放弃(target 填 \"none\")" if allow_none else ""
|
||
instruction = (
|
||
f"{prompt}\n候选玩家:{'、'.join(candidates)}{opt}。\n"
|
||
"请只返回 JSON:{\"target\": \"玩家名或none\", \"reason\": \"一句话理由\"}"
|
||
)
|
||
raw = self._chat(instruction, players, max_tokens=120, json_mode=True)
|
||
self.last_decision_reason = self._parse_reason(raw)
|
||
target = self._parse_target(raw, candidates, allow_none)
|
||
return target
|
||
|
||
def vote(self, candidates: List[str], players: List[str]) -> Optional[str]:
|
||
"""投票放逐。返回票投给谁(或弃票 none)。"""
|
||
if self.offline:
|
||
return self._offline_vote(candidates)
|
||
instruction = (
|
||
"现在是白天投票放逐环节。请根据全场发言与你的推理,投出你认为最可能是"
|
||
"狼人的玩家。好人阵营必须按证据强度决策:没有对跳且已报告自洽查验结果的"
|
||
"预言家声明是当前最强公开证据;除非有具体矛盾或另一名预言家对跳,不得投该"
|
||
"声明者。若其报告某玩家是狼人,应优先投被查杀者;被查杀者仅仅否认并不构成"
|
||
"矛盾或对跳。投票理由必须引用具体发言、查验或既有票型,不得随机猜测。\n"
|
||
"候选玩家:" + "、".join(candidates) + "。\n"
|
||
"请只返回 JSON:{\"target\": \"玩家名\", \"reason\": \"一句话理由\"}"
|
||
)
|
||
raw = self._chat(instruction, players, max_tokens=120, json_mode=True)
|
||
self.last_decision_reason = self._parse_reason(raw)
|
||
return self._parse_target(raw, candidates, allow_none=True)
|
||
|
||
# ---------- 离线(规则)策略:只读自己的 memory,绝不访问他人上下文 ----------
|
||
def _known_teammates(self) -> set:
|
||
"""狼人从自己的私有上下文里解析出队友名单(好人解析不到,返回空)。"""
|
||
mates = set()
|
||
for m in self.memory:
|
||
hit = re.search(r"狼人阵营的玩家是:([^((]+)", m)
|
||
if hit:
|
||
mates |= set(re.findall(r"P\d+", hit.group(1)))
|
||
return mates
|
||
|
||
def _known_wolves(self) -> set:
|
||
"""从自己的私有上下文里收集『已知是狼人』的玩家:预言家的查验结果 + 狼人的队友。
|
||
|
||
好人平民无从得知任何人身份 → 返回空集合,只能随机投票。这正是信息不对称。
|
||
"""
|
||
known = set(self._known_teammates())
|
||
for m in self.memory:
|
||
hit = re.search(r"你查验了\s*(P\d+),结果为【狼人】", m)
|
||
if hit:
|
||
known.add(hit.group(1))
|
||
return known
|
||
|
||
def _offline_vote(self, candidates: List[str]) -> Optional[str]:
|
||
"""离线投票:优先投自己『确知的狼人』(预言家验人 / 狼人不投队友),否则随机。"""
|
||
if not candidates:
|
||
return None
|
||
if self.role == Role.WEREWOLF:
|
||
# 狼人:投一个非队友的好人,尽量隐藏自己
|
||
mates = self._known_teammates()
|
||
targets = [c for c in candidates if c not in mates] or candidates
|
||
return self._rng.choice(targets)
|
||
# 好人:预言家有验人结果就投确认的狼;其余平民只能随机(信息不对称的代价)
|
||
wolves = [c for c in candidates if c in self._known_wolves()]
|
||
if wolves:
|
||
return self._rng.choice(wolves)
|
||
return self._rng.choice(candidates)
|
||
|
||
def _offline_choose_target(self, candidates: List[str],
|
||
allow_none: bool) -> Optional[str]:
|
||
"""离线夜间选目标:狼人/预言家等必选场景优先选『已知狼人之外』的目标;
|
||
女巫解药/毒药等可放弃场景按概率决定。"""
|
||
if not candidates:
|
||
return None
|
||
if allow_none:
|
||
# 女巫用药:约一半概率行动(救/毒),使对局有变化又能收敛
|
||
if self._rng.random() < 0.5:
|
||
return None
|
||
return self._rng.choice(candidates)
|
||
if self.role == Role.SEER:
|
||
# 预言家:优先查验尚未确认身份的玩家(避免重复查验已知狼人)
|
||
unknown = [c for c in candidates if c not in self._known_wolves()]
|
||
return self._rng.choice(unknown or candidates)
|
||
if self.role == Role.WEREWOLF:
|
||
mates = self._known_teammates()
|
||
targets = [c for c in candidates if c not in mates] or candidates
|
||
return self._rng.choice(targets)
|
||
return self._rng.choice(candidates)
|
||
|
||
def _offline_speak(self, candidates: List[str]) -> str:
|
||
"""离线发言:按角色生成一句符合身份、且不泄露私密信息的模板发言。"""
|
||
wolves = [c for c in candidates if c in self._known_wolves()]
|
||
suspect = self._rng.choice(candidates) if candidates else "大家"
|
||
if self.role == Role.SEER and wolves:
|
||
return f"我是预言家,昨晚查验到 {wolves[0]} 是狼人,请大家把票投给他。"
|
||
if self.role == Role.WEREWOLF:
|
||
return f"我是好人,从发言看 {suspect} 有点可疑,建议重点关注他。"
|
||
if self.role == Role.WITCH:
|
||
return f"我暂时观望,觉得 {suspect} 的发言站不住脚,先留意一下。"
|
||
if self.role == Role.SEER:
|
||
return "我还没有决定性的信息,先听大家发言,谨慎投票。"
|
||
return f"我是村民,没有夜间信息,只能靠推理,感觉 {suspect} 稍微可疑。"
|
||
|
||
# ---------- 解析工具 ----------
|
||
@staticmethod
|
||
def _parse_reason(raw: str) -> Optional[str]:
|
||
try:
|
||
reason = json.loads(raw).get("reason")
|
||
except Exception:
|
||
return None
|
||
return reason.strip() if isinstance(reason, str) and reason.strip() else None
|
||
|
||
@staticmethod
|
||
def _parse_target(raw: str, candidates: List[str], allow_none: bool) -> Optional[str]:
|
||
target = None
|
||
try:
|
||
data = json.loads(raw)
|
||
target = str(data.get("target", "")).strip()
|
||
except Exception:
|
||
# 兜底:直接从文本里正则找候选玩家名
|
||
target = raw
|
||
if allow_none and target.lower() in ("none", "", "弃票", "放弃"):
|
||
return None
|
||
# 归一化:精确匹配优先
|
||
if target in candidates:
|
||
return target
|
||
# 其次:从原始串里搜 Pn 精确 token。必须先于子串匹配——
|
||
# 否则 10 人以上的局里 "P10(他最可疑)" 会先命中 "P1"。
|
||
m = re.search(r"P\d+", target or "")
|
||
if m and m.group(0) in candidates:
|
||
return m.group(0)
|
||
# 最后兜底:子串匹配,最长的候选名优先(避免 P1 抢先命中 P10)
|
||
for c in sorted(candidates, key=len, reverse=True):
|
||
if c in (target or ""):
|
||
return c
|
||
# 实在解析不出:好人默认弃票,狼人/必须选的场景由调用方兜底
|
||
return None if allow_none else (candidates[0] if candidates else None)
|