Agnet狼人杀

我怎么发现「AI 笨」不是模型问题,而是我的统计在骗我

一个 6 个 LLM 智能体的信息不对称博弈系统,以及我在它身上踩过的三个「假信号」。


一、AI 好像变笨了

事情是这样的。

我写了个狼人杀 AI 对战系统:6 到 12 个 LLM 智能体坐一桌,各自扮演预言家、女巫、狼人、村民,互相发言、投票、欺骗。我坐在浏览器前以上帝视角围观。

跑了几局之后,观感很明确:这帮 AI 有点笨。

典型表现是悍跳质量差------狼人跳预言家,报的验人信息前后矛盾;或者好人被踩了两句就情绪激动地自爆身份。看着就像模型能力不行。

于是我打开 prompts.py,准备加推理素材。这是一条所有做过 LLM 应用的人都很熟悉的路:效果不好 → 调 prompt → 还是不好 → 怀疑模型太弱 → 换更大的模型。

我差点就这么一路走下去了。阻止我的,是这个项目里我最满意的一个设计------三个计数器


二、先别急着调 prompt:把「笨」和「挂了」拆开

在动手改 prompt 之前,我先问了自己一个问题:

我看到的「笨」,到底是模型真的没推理出来 ,还是请求根本没成功,代码悄悄降级成了随机决策

这两种情况在观感上完全一样------都是「AI 说了句没营养的话」。但成因和修法南辕北辙:前者要改 prompt 或换模型,后者要改工程参数。

如果你分不清这两者,你会拿着一个工程问题去反复折磨你的 prompt。

所以我把所有可能让 AI 退化成随机玩家的路径,全部拆开单独计数:

python 复制代码
# 运行期统计:没有度量就无法判断"变没变聪明"
STATS = {
    "calls": 0,
    "failures": 0,
    "retries": 0,
    "fallbacks": 0,
    "degraded": 0,         # thinking 吃满 token 导致 content 为空
    "speech_rejected": 0,  # 因无座号被打回
}

这三个计数器各自对应一条真实的失败路径:

fallbacks ------ 彻底挂了,走随机兜底。

重试耗尽后返回空,调用方拿不到有效决策,只能用随机目标顶上:

python 复制代码
async def _call_with_retry(...) -> str:
    """指数退避重试。网络抖一下不该让 AI 变成随机玩家。"""
    for attempt in range(RETRY_TIMES):
        try:
            return await asyncio.to_thread(_call_sync, messages, temperature, max_tokens, model)
        except Exception as e:
            if attempt < RETRY_TIMES - 1:
                wait = min(1.5 * (2 ** attempt), 8.0)
                STATS["retries"] += 1
                log.warning("调用失败(%s/%s) action=%s err=%s --- %.1fs 后重试", ...)
                await asyncio.sleep(wait)
    STATS["failures"] += 1
    log.error("调用彻底失败 action=%s err=%s ------ 将走 fallback(这不是 AI 笨,是请求挂了)", ...)
    return ""

注意那行日志------「这不是 AI 笨,是请求挂了」。这句话是写给三个月后的我自己看的。

retries ------ 抖了一下,重试成功了。 这种情况 AI 表现正常,但延迟变高,值得知道。

degraded ------ 最隐蔽的一种,也是我这次抓到的真凶。

抓到真凶:14% 的降级率

DeepSeek 这类带 thinking 的模型有个坑:思考过程本身要消耗 token 。如果 max_tokens 给得太小,模型把配额全花在思考上,最后返回的 content空的

空响应不会抛异常。它看起来只是「模型没说话」,然后触发我的兜底逻辑------表现得和「AI 笨」一模一样

我给它单独开了一个计数器:

python 复制代码
content = response.choices[0].message.content
if not content:
    # thinking 吃光了 token,或模型返回空 ------ 降级重试一次(关 thinking、加倍 token)
    STATS["degraded"] += 1
    log.warning("空响应触发降级重试 action_model=%s max_tokens=%s", use_model, max_tokens)
    response = client.chat.completions.create(
        model=use_model,
        max_tokens=max(max_tokens, 8192),
        temperature=temperature,
        messages=messages,
        # 注意:这一次不带 thinking 参数
    )
    content = response.choices[0].message.content

跑完统计,答案出来了:

复制代码
6 人局实测 35 次调用中,512/1024 档共降级 5 次(14%),8192 档 0 次。

14% 的调用根本没拿到有效输出。 也就是说,我看到的「笨」,有相当一部分压根不是推理质量问题------是 token 给小了,模型思考到一半被截断。

修法是把 max_tokens 下限统一抬到 2048:

python 复制代码
# 实测:给太小会返回空内容(模型有思考开销),触发降级重试、白白多花一次调用。
# 因此下限统一抬到 2048。

抬高下限之后,降级率回落到个位数------当前所有对局的聚合值是 约 3.8%(这个数字口径比上面那次宽,不同板子混在一起统计,不是严格的前后对照,但量级差异足够说明问题)。

如果我没有这个计数器,我会去改 prompt。 我会在 prompt 里加更多推理素材、写更严厉的指令、反复调措辞------全都是白费力气,因为我修的是 A 问题,而真正的病灶在 B。

这就是那句让我印象最深的话的来源,也是我认为所有 LLM 应用都该有的东西:

统一日志。所有 fallback / 重试 / 降级都会走到这里------

目的是让「AI 真的笨」和「AI 挂了」这两种情况永远能被区分开。

大部分 AI 项目没有这个。 它们的日志里只有「输出不好」,没有「输出为什么不好」。


三、比调 prompt 更要命的:信息不对称

解决了「挂了」的问题,剩下才是「真笨」。而对一个多智能体博弈系统来说,「笨」的头号成因不是模型不行,是信息给错了。

狼人杀的本质是不完全信息博弈。规则规定:

  • 狼人知道同伴是谁,好人不知道
  • 预言家每晚验一个人,只有他自己知道结果
  • 女巫知道昨晚谁被刀了,只有她知道
  • 夜死的人不翻牌 ,死因不公布

这套规则存在的唯一目的,就是制造信息差。如果你在实现时手一抖,让 AI 看到了它不该看的东西,博弈立刻就瓦解了------好人瞬间全知全能,狼人无处可藏,剩下的只是走流程。

这是这类项目最容易做错的地方,也是我做的最重的一个设计决策。

用数据结构保证隔离,而不是靠自觉

常见做法是「写 prompt 时小心点,别把机密塞进去」。这个做法的问题是:它依赖每一个写 prompt 的人(包括三个月后的我)每次都记得。只要漏一次,就泄漏了。

我改成两条物理隔离的事件通道

python 复制代码
def add_public_event(self, event_type: str, content: str, speaker_id=None):
    """公开信息:AI 玩家可见,前端也可见。
    只放真实规则下全员都应知道的内容:发言、投票、死亡名单、跳身份。"""
    entry = {...}
    self.public_history.append(entry)
    self.all_events.append({**entry, "visible_to": "all"})

def add_spectator_event(self, event_type: str, content: str, speaker_id=None):
    """机密信息:只有浏览器前的观众能看到,绝不进 AI 玩家时间线。
    女巫救了谁、守卫守了谁、验人结果------这些会直接抹掉推理空间。"""
    entry = {...}
    self.all_events.append({**entry, "visible_to": "spectator"})

关键在于:prompts.py 从头到尾没有读过 all_events

我可以用一个 grep 证明这一点------整个 prompt 层对观战通道的读取次数是 0

复制代码
$ grep -c "all_events" prompts.py
0

AI 能看到的只有 public_history。这不是靠写 prompt 时小心,而是数据结构上就不可能读到。想泄漏,得先改数据结构------那是一个显眼的、会被 code review 拦下的动作。

三个具体的不完全信息处理

1. 夜死不翻牌、死因不公布

玩家看到的和观众看到的必须是两份文本:

python 复制代码
def night_deaths_public(state, deaths) -> str:
    """玩家可见版本:只公布死亡名单与平安夜。
    死因、女巫救了谁、守卫守了谁一律不公布------真实规则如此,
    泄漏这些信息等于给好人开透视,推理空间会被直接抹掉。"""
    if not deaths:
        return "🌙 天亮了,昨晚是平安夜,无人死亡。"
    names = "、".join(f"{state.get_player(pid).name}({pid}号)" for pid in deaths)
    return f"🌙 天亮了,昨晚 {names} 倒牌了。"

而观众看到的是带完整细节的 night_action_summary()------谁被刀、谁被救、谁被毒。

2. 决赛圈提示:好人给上界,狼人给精确数

这是我觉得最像「教科书式的部分可观测处理」的一处。存活人数 ≤5 时,要给 AI 一个「这票投错就输」的紧迫感。但如果直接告诉所有人「场上还剩 2 头狼」,好人就开天眼了。

正确的是:

  • 好人 只拿「本局共 N 头狼 − 已公开翻牌 M 头 = 场上最多还有 N−M 头」------上界
  • 狼人拿精确的存活同伴数

而这个上界的计算依据(revealed_wolves)只在放逐、猎人枪杀、白狼王自爆 时登记。夜死不算------因为夜死不翻牌,好人本来就不该知道死的是不是狼。

3. 真人下场时,连你自己都不知道别人的身份

同一个引擎要跑两种视角。观众是上帝视角,真人玩家则必须被降级到「和 AI 同等的信息量」:

python 复制代码
def to_dict_hidden(self) -> dict:
    """人类玩家视角:隐藏所有身份,历史只含公开事件。
    否则真人一下场就开透视,博弈无从谈起。"""
    d = self.to_dict()
    d["players"] = [p.to_dict_public() for p in self.players]  # 剔除 role
    d["history"] = self.public_history[-30:]                    # 只给公开事件
    d["werewolf_count"] = None                                  # 不给人类透底
    return d

验人结果、女巫用药这些私密信息,也只在真人模式下单独回传给本人,不进广播通道。


四、反转:然后我发现,度量本身也在骗我

前面讲的是一个「我造了好的度量,它救了我」的故事。

如果文章到这里结束,它会是一篇还不错的经验分享。但我在复盘阶段做了一件事------去审计我的度量本身------然后发现:

我的统计在骗我,而且骗得很精确。

第一个假信号:精确翻倍的查验数

我在核对日志时注意到一个诡异的现象:

复制代码
night 事件 70 / 带 seer_check 48 / is_wolf 20

48 和 20,全是偶数。

连续两个独立的计数都恰好是偶数,概率太低了。我去翻代码,找到了原因------

同一个夜晚结算流程里,game_logger.night() 被调用了两次

python 复制代码
if game_logger:
    game_logger.night(state.round_number, state.night_kill_target,
                      seer_check_info, list(deaths))      # 第一次

# ... 中间处理猎人开枪 ...

if game_logger:
    game_logger.night(state.round_number, state.night_kill_target,
                      seer_check_info, public_deaths)     # 第二次

两次调用传的 seer_check_info 完全相同 ,只有 deaths 参数不同(第二次追加了猎人带走的)。而聚合脚本对每个 night 事件无条件累加:

python 复制代码
elif e["type"] == "night":
    chk = e.get("seer_check")
    if chk:
        seer_checks += 1
        if chk.get("is_wolf"):
            seer_found_wolves += 1
            checked_wolves.add(chk["target_id"])

于是每一次查验都被计了两次

后果不止是数字难看。我有个指标叫查杀执行率------「预言家验出来的狼,最终有没有被投出去」,用来衡量好人阵营的执行力。它的算法是:

python 复制代码
查杀执行率 = checked_wolf_executed / seer_found_wolves

分子 checked_wolf_executed 遍历的是 checked_wolves 这个 set (天然去重),而分母 seer_found_wolves 是个累加计数器(没去重)。

分子对了,分母翻倍。

于是这个指标被系统性地砍掉一半:

报出来的 真实值
查验次数 32 16
验出查杀 14 7
查杀执行率 0.429 约 0.86

我的 AI 表现得比我自己记录的好了整整一倍,而我差点拿着这个被砍半的数字,去给「AI 到底会不会推理」下一个悲观结论。

这个 bug 最值得玩味的地方在于:它不是让数据变好看,是让数据变难看。 它没有欺骗我去偷懒,而是欺骗我去做了一个错误的自我否定。

第二个假信号:一个专门防失真的函数,自己制造了失真

AI 发言时,除了台词本身,还会输出一个结构化的「我对外声称的身份」。我把它记进登记表,供其他 AI 推理用------谁跳了预言家,是狼人杀里权重最高的推理素材。

我担心模型嘴上说的和标签写的不一致,于是写了个函数来对齐:

python 复制代码
def _reconcile_claim(claim: str, speech: str) -> str:
    """public_claim 与台词不一致时以台词为准------避免跳身份登记表失真。"""
    roles = ["预言家", "女巫", "猎人", "守卫", "白痴", "村民"]
    spoken = [r for r in roles if r in (speech or "")]
    if spoken and claim not in spoken:
        return spoken[0]
    return claim or "村民"

意图完全正确。但它判断「你是跳了什么身份」的依据,是「这个角色词有没有出现在你的台词里」。

我实际跑了一下:

python 复制代码
>>> _reconcile_claim('村民', '10号你首置位就跳预言家报查杀0号,我先不把你按死')
'预言家'

>>> _reconcile_claim('女巫', '我觉得预言家的验人不可信')
'预言家'

一个村民,只要在发言里提到「预言家」三个字------哪怕是在质疑别人------登记表里他就成了「公开自称预言家」。

这个函数的设计目标是防止登记表失真。而它自己,成了登记表失真的最大来源。它污染的恰恰是整个系统里权重最高的那个推理输入。

第三个假信号:一个永远没人读的功能

我在 README 里把一个特性写得挺得意:

结构化记忆 :模型在发言时自己输出 <memory_update> JSON 记录「谁可疑、我的策略是什么」,取代早期靠关键词刮文本的做法。

复盘时我 grep 了一遍这三个字段的读取方:

复制代码
ai_player.py:520:            player.current_strategy = v
ai_player.py:522:            player.player_judgments[k] = v
ai_player.py:525:        player.memory_notes.append(note[:80])
game_engine.py:135:    player_judgments: dict[str, str] = field(default_factory=dict)
game_engine.py:136:    current_strategy: str = ""
game_engine.py:137:    memory_notes: list[str] = field(default_factory=list)

全是写入和声明,零读取。

模型辛苦输出的记忆 JSON,被解析、被存进对象、然后没有任何一处 prompt 读过它 。真正在起作用的是每个玩家独立的 messages 多轮对话历史------跟这个「结构化记忆」没有半点关系。

一个在 README 里被当作亮点介绍的功能,实际是个只写不读的死管道


五、怀疑模型,也要怀疑尺子

把这三件事放在一起看,我意识到它们是同一个错误的三次重演:

假信号 我以为的事实 实际的事实
降级率 14% AI 推理能力不行 token 给小了,输出是空的
查杀执行率 0.43 好人执行力差 分母翻倍,真实是 0.86
结构化记忆 记忆提升了推理质量 这个功能根本没接入

第一个是度量抓到了真相 。后两个是度量本身在说谎

而我一开始的直觉反应------去改 prompt------在三种情况下都是错的。

所以我现在的做法是:

第一步,先把「笨」和「挂了」拆开。 任何一条会让 AI 退化成随机玩家的路径,都必须有独立的计数器。没有这个,你连自己看到的是什么都说不清楚。

第二步,再把「我」和「尺子」拆开。 度量本身也要被验证:

  • 计数器的奇偶性、整除性------48 和 20 全是偶数,这个「太巧了」就是线索。任何一个「应该近似随机」的计数出现了可疑的规整性,都值得挖。
  • 一个指标的分母是什么。分子去重、分母不去重,这种错误能潜伏几个月不被发现。
  • 每个数据结构的字段,写完之后谁读它。只写不读的字段,就是死代码,不管它在 README 里写得多漂亮。

最后回到标题。

「我怎么发现 AI 笨不是模型问题而是工程问题」------这句话本身是一次成功的归因。但更值钱的,是它后面那半句:

我怎么发现,我用来判断 AI 笨不笨的那把尺子,自己就是歪的。

对一个 LLM 应用来说,模型能力往往是你能改变的变量里最不重要的那个。真正决定成败的,是你能不能说清楚:它到底是没做好,还是你根本没看清楚它做了什么。


项目地址:deepseek-werewolf-kill(FastAPI + WebSocket + React,支持 DeepSeek / Anthropic 双服务商)

相关推荐
anxiao_m42 分钟前
不同行业适配哪些云桌面?四大主流品牌场景适配详解
网络·数据库·人工智能
AI人工智能集结号42 分钟前
AI搜索可见度监测软件有哪些?选择时如何区分功能范围
大数据·人工智能
Είναι η κοπέλα1 小时前
YOLO 演进 v5→v11 与 2026 选型指南
人工智能·yolo
hhzz1 小时前
【OpenCV 入门到精通 04】视频入门与绘图交互:从摄像头到鼠标画笔
人工智能·python·opencv·计算机视觉·音视频·交互
Thomas.Sir1 小时前
第40课:TensorFlow|模型推理部署前置知识【离线推理、接口调用基础】
人工智能·python·tensorflow
王解1 小时前
CL-04_nanoAgent:103 行极简实现到完整 Agent
人工智能·prompt
2601_962387821 小时前
多源数据查询指南:MySQL 联邦、Python 聚合与 Presto 引擎全对比
python·mysql·数据集成·presto·联邦查询
元岳数字人小元1 小时前
数字人交互的用户体验设计与场景交互感受
运维·人工智能·开源·人机交互·交互
weixin199701080161 小时前
《Mercari OTA 直连接入:日本二手电商出海的API通道与600次/分钟限流实战》(附Python源码)
开发语言·数据库·python