ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
# -*- 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)