Files
liqiang b119135836
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
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

332 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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) 否则直连 OpenAI3) 最后才回退 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 消息并调用 LLMuser 消息里只拼接本 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)