我怎么发现「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 双服务商)