尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

LLM评测新玩法:物理仿真剑术对决与盲评系统设计

LLM评测新玩法:物理仿真剑术对决与盲评系统设计 最近在折腾 LLM 评测的时候我一直在想一个问题传统问答评测只能看到模型“知不知道”却很难看出模型“会不会临场决策”。后来看到一个很有意思的玩法——让两个大模型进入同一个物理仿真环境用剑术对战的方式互相博弈最后由人类盲评“谁更像在认真推理”。这个思路把 LLM 从静态答题里拉了出来放进了一个带空间、血量、体力和对抗关系的动态场景。本文会完整拆解这个实验项目的设计与实现包括一个可运行的 2D 剑术对决 Demo、LLM 决策接入物理仿真的方式、盲评系统的设计思路以及我在落地过程中踩到的一些坑。无论你是对 LLM Agent 感兴趣还是想找一些物理仿真和 AI 结合的可玩项目都可以照着本文跑一遍。1. 背景为什么让两个 LLM 在物理仿真里剑术对决1.1 从静态问答到动态对抗目前主流的 LLM 评测方法大多是“给一个问题让模型输出答案再和参考答案对比”。这种方式能验证知识储备和基础推理但很难体现模型在连续决策、资源管理、对手博弈这些能力上的差异。比如同样面对“敌人离你很近但你体力不足”的局面有的模型会冒险进攻有的会保守格挡有的甚至会因为上下文理解错误而输出一个根本不存在的动作。这些差异只有在动态环境里才会真实暴露出来。把两个 LLM 放进同一个物理仿真世界让它们各自控制一个角色进行对抗本质上是在做一个“沙盒式压力测试”。模型需要读取当前位置、距离、血量、体力、冷却时间等信息然后从有限的动作空间里选择下一步行动。这个过程中既要考虑短期的攻防收益也要考虑长期的体力和血量分配比单纯回答“你该怎么打”要复杂得多。1.2 项目运行后的实际效果你最终会看到这样一个流程系统创建两个 Agent分别代表“左方剑客”和“右方剑客”它们轮流或者按固定步长从 LLM 接口获取决策然后把决策转成物理世界里的位移、攻击、格挡等动作。物理模拟器更新双方的位置和状态并判定攻击是否命中、格挡是否生效。整个过程会记录成一份回放文件里面包含每一步的状态快照和模型给出的动作理由。等到对战结束后人类评审者打开盲评脚本看到的是“A 阵营 vs B 阵营”的回放列表而不是模型的名字和参数规模。评审者根据动作合理性、局势判断、攻防节奏等维度打分最后投票判断谁更擅长推理。这样做的好处是人类在不知道模型身份的情况下做评估可以尽量减少“这个模型听说很厉害”这类先入为主的干扰。1.3 核心关键词拆解这个项目里最核心的三个概念是 LLM、Physics SIM 和盲评。LLMLarge Language Model大语言模型负责“决策”根据当前战局状态输出下一步动作。Physics SIM物理仿真负责“执行”把动作映射成位移、攻击判定、碰撞等物理结果。盲评Blind Review负责“评估”人类看不到底层模型身份只能看到随机打乱后的 A/B 阵营从而更客观地评价模型的推理表现。三者形成一个闭环LLM 决策 - 物理仿真执行 - 回放记录 - 人类盲评 - 反过来优化决策策略。这个闭环也可以套用到很多类似场景比如无人车避障、游戏 NPC 行为设计、多智能体协作等。2. 整体架构与核心原理2.1 系统组成整个系统可以拆成四个模块。第一个模块是物理世界。它维护场地宽度、双方位置、速度、血量、体力、攻击冷却等状态并实现移动、碰撞、攻击、格挡等规则。为了便于复现本文的示例里我直接用一个纯 Python 的极简 2D 模拟器避免引入过多依赖。如果你希望更真实可以换成 Box2D、MuJoCo 或 Bullet 这样的物理引擎。第二个模块是 Agent。Agent 是决策单元它接收当前战局状态输出一个动作和一个决策理由。针对这个实验我实现了两种 Agent一种是规则 Agent用 if-else 写出基线策略另一种是 LLM Agent把战局状态拼成 Prompt让大模型返回 JSON 格式的动作和理由。第三个模块是对战主循环。它负责交替调用两个 Agent 的决策接口把动作交给物理世界执行然后检查胜负条件。模拟过程中每一步都会记录状态、动作和理由写入回放数据供后续盲评使用。第四个模块是盲评脚本。它读取回放文件把左右阵营随机打乱成 A/B 阵营隐藏模型身份然后以 CLI 交互方式让人类评审打分和投票。2.2 LLM 与物理世界如何交互很多人第一次接触这个项目会困惑LLM 不是只处理文本吗它怎么和物理仿真通信答案是通过“状态字符串化 JSON 动作”的桥接方式。每个决策回合开始时物理世界把当前状态序列化为一段文本例如“你的位置是 12.0对手位置是 8.0距离 4.0血量 76.5体力 45.0攻击冷却 0.2”。这段文本作为 Prompt 发送给 LLMLLM 输出一个 JSON例如{action: attack, reason: 距离合适且冷却结束}。主循环解析这个 JSON把 action 字段映射成物理模拟器能识别的动作枚举然后调用模拟器的 step 函数推进一帧。这里的关键点是我们不是让 LLM 直接控制物理引擎而是让它做“决策”物理规则仍然由模拟器保证。即使 LLM 输出了类似“闪现到对手身后”这种不存在的动作模拟器也会按非法动作处理并回退到 idle不会让模型绕过规则修改游戏状态。这个设计非常重要后面最佳实践部分还会专门展开。2.3 为什么用盲评而不是自动指标有人可能会问既然是对战游戏直接用“谁赢了”作为指标不就行了赢当然是一个结果指标但它的信息量不够。一个模型可能只会无脑进攻因为随机种子恰好让它在某个回合命中了对手所以赢了另一个模型虽然避开了大部分攻击但因为输出 JSON 时偶尔解析失败导致多次错过反击机会最终输了。这两个模型在“推理能力”上的表现其实完全不同但只看胜率很难体现出来。盲评的价值在于它把“结果”和“过程”结合在一起做评估。人类评审可以看到每个回合的状态变化和模型给出的理由可以从动作是否符合战况、是否随局势调整策略、攻防是否平衡、决策是否稳定等维度做出更细致的判断。而“盲”的部分则是为了避免品牌偏见、参数量偏见和名气偏见。比如评审者如果知道左边是某知名大模型右边是开源小模型打分时很容易不自觉地抬高知名模型。随机打乱阵营标识后这种倾向就被大大削弱。3. 环境准备与设计约定3.1 运行环境在开始写代码之前先确认一下运行环境。本文示例以 Python 3.10 为例操作系统可以是 Windows、macOS 或 Linux主要依赖库如下openai用于调用兼容 OpenAI SDK 的大模型接口可以对接云端 API也可以对接本地模型的兼容服务。python-dotenv可选用于从.env文件加载环境变量方便管理 API Key。物理模拟部分我刻意不依赖第三方物理引擎而是写了一个非常轻量的 2D 模拟器方便你直接复制运行。如果你希望把示例换成 Box2D 或 MuJoCo只需要把模拟器内部的 step 函数替换成对应引擎的调用即可Agent 和盲评模块不需要大改。版本说明不同 SDK 版本的OpenAI初始化方式和chat.completions.create参数可能会略有差异。本文示例以目前通用的 openai Python SDK 写法为例如果你用的版本更老或更新需要按实际版本调整。3.2 项目结构建议创建如下项目结构llm-duel/ ├── config.py ├── physics.py ├── agent.py ├── battle.py ├── blind_review.py ├── requirements.txt └── .env.example每个文件的职责如下physics.py定义 Fighter 和 World 类实现简化的物理规则。agent.py定义 AgentBase、RuleAgent、LLMAgent。battle.py对战主循环生成回放文件。blind_review.py盲评脚本读取回放并交互式打分。config.py集中管理配置项。requirements.txt记录 Python 依赖。.env.example环境变量示例。3.3 动作空间定义为了让 LLM 有足够的决策空间又不会因为动作太多导致输出失控我定义了以下动作move_left / move_right左右平移速度中等体力消耗很小。advance向对手方向突进速度更快体力消耗稍高。retreat向远离对手方向后退用于拉开距离或规避攻击。block进入格挡状态减少受到的伤害但会消耗体力。attack挥剑攻击有冷却时间消耗体力较大命中后造成伤害。idle原地待机缓慢恢复体力。这些动作都比较直观LLM 即使没有专门训练也能理解“advance 是前进、retreat 是后退”。为了让模型更容易输出合法 JSON我在 Prompt 里明确列出了可选动作并且要求只输出 JSON不输出额外解释。4. 实现一个最小可运行的剑术对决 Demo下面我们开始写代码。我会按照文件路径逐个讲解你可以直接把代码复制到本地运行。4.1 物理场与角色模型 physics.py这个文件是模拟器的核心。我用极简方式实现了一个横向 2D 战场双方只能沿 x 轴移动通过距离判断攻击是否命中。# 文件路径llm-duel/physics.py 一个极简 2D 横向剑术对战物理模拟器。 使用欧拉积分更新位置使用 AABB 碰撞盒做攻击与格挡判定。 真实项目中建议替换为 Box2D / MuJoCo / PhysX 等物理引擎。 import random class Fighter: def __init__(self, name, x, direction1, seed0): self.name name self.x x self.vx 0.0 self.hp 100.0 self.stamina 100.0 self.direction direction # 1 朝右-1 朝左 self.attack_cd 0.0 self.blocking False self.hit_stun 0.0 self.width 1.0 self.height 2.0 self.rng random.Random(seed) def reset(self, x, direction): self.x x self.vx 0.0 self.hp 100.0 self.stamina 100.0 self.direction direction self.attack_cd 0.0 self.blocking False self.hit_stun 0.0 def to_dict(self): return { name: self.name, x: round(self.x, 2), vx: round(self.vx, 2), hp: round(self.hp, 1), stamina: round(self.stamina, 1), direction: self.direction, attack_cd: round(self.attack_cd, 2), blocking: self.blocking, hit_stun: round(self.hit_stun, 2), } class World: def __init__(self, seed42, arena_width20.0): self.seed seed self.arena_width arena_width self.time 0.0 self.record [] self.rng random.Random(seed) self.left Fighter(left, x6.0, direction1, seedseed) self.right Fighter(right, x14.0, direction-1, seedseed 1) def get_state(self): return { time: round(self.time, 2), left: self.left.to_dict(), right: self.right.to_dict(), } def step(self, action_left, action_right, dt0.1): self.time dt self.left.attack_cd max(0.0, self.left.attack_cd - dt) self.right.attack_cd max(0.0, self.right.attack_cd - dt) self.left.hit_stun max(0.0, self.left.hit_stun - dt) self.right.hit_stun max(0.0, self.right.hit_stun - dt) self._apply_action(self.left, action_left, dt) self._apply_action(self.right, action_right, dt) self._resolve_collision(self.left, self.right) self._resolve_attack(self.left, self.right, action_left, action_right, dt) for f in (self.left, self.right): if f.x 1.0: f.x 1.0 f.vx 0.0 if f.x self.arena_width - 1.0: f.x self.arena_width - 1.0 f.vx 0.0 self.record.append({ time: self.time, state: self.get_state(), actions: {left: action_left, right: action_right}, }) return self.get_state() def _apply_action(self, fighter, action, dt): fighter.blocking False if fighter.hit_stun 0: fighter.vx * 0.5 return action_name action.get(action, idle) if action_name move_left: fighter.vx -3.0 fighter.stamina max(0, fighter.stamina - 0.1) elif action_name move_right: fighter.vx 3.0 fighter.stamina max(0, fighter.stamina - 0.1) elif action_name advance: fighter.vx 4.0 * fighter.direction fighter.stamina max(0, fighter.stamina - 0.3) elif action_name retreat: fighter.vx -4.0 * fighter.direction fighter.stamina max(0, fighter.stamina - 0.3) elif action_name block: fighter.blocking True fighter.vx 0.0 elif action_name attack: if fighter.attack_cd 0 and fighter.stamina 10: fighter.attack_cd 0.8 fighter.stamina max(0, fighter.stamina - 10) fighter.vx 0.0 elif action_name idle: fighter.vx * 0.6 else: fighter.vx * 0.6 fighter.x fighter.vx * dt fighter.stamina min(100.0, fighter.stamina 1.5 * dt) def _resolve_collision(self, left, right): overlap (left.width right.width) / 2 - abs(left.x - right.x) if overlap 0: if left.x right.x: shift overlap / 2 left.x - shift right.x shift else: shift overlap / 2 left.x shift right.x - shift left.vx * -0.2 right.vx * -0.2 def _resolve_attack(self, left, right, action_left, action_right, dt): if action_left.get(action) attack and left.attack_cd 0.7: self._damage(attackerleft, defenderright, dtdt) if action_right.get(action) attack and right.attack_cd 0.7: self._damage(attackerright, defenderleft, dtdt) def _damage(self, attacker, defender, dt0.1): distance abs(attacker.x - defender.x) if distance 2.6: return if defender.blocking: defender.stamina max(0, defender.stamina - 2.0) defender.vx attacker.direction * 1.5 return base_damage 8.0 random.uniform(0, 2.0) defender.hp - base_damage defender.hit_stun 0.3 defender.vx attacker.direction * 3.0 def judge(self): if self.left.hp 0 and self.right.hp 0: return draw if self.left.hp 0: return right if self.right.hp 0: return left return None def replay(self): return { seed: self.seed, arena_width: self.arena_width, max_round: len(self.record), record: self.record, }代码不长但包含几个关键设计点。首先是Fighter类它保存角色的基础属性。这里我用hp表示生命值stamina表示体力attack_cd表示攻击冷却时间blocking表示是否处于格挡状态hit_stun表示受击硬直。方向direction用于判断“前”和“后”左边角色朝右右边角色朝左。然后是World类它负责维护整个战场。step方法每调用一次就推进一帧默认步长为 0.1 秒。每一帧依次执行冷却递减、动作应用、碰撞处理、攻击判定和边界约束。为了后续盲评每一帧的状态、两个 Agent 的动作和理由都会被记录到self.record中。具体到攻击判定逻辑只有当前回合 Agent 确实选择了 attack并且攻击冷却刚触发时才会执行伤害结算。伤害结算时先判断距离是否在攻击范围内再判断对手是否处于格挡状态。如果格挡成功对手只损失少量体力不会被击退太远如果格挡失败则造成 8 到 10 点伤害并给对手附加 0.3 秒的受击硬直。4.2 决策 Agent agent.py有了物理世界下一步就是让模型能够“控制”角色。Agent 接收当前战局状态返回一个包含 action 和 reason 的字典。# 文件路径llm-duel/agent.py Agent 抽象与两个实现 1. RuleAgent基于规则的基线 Agent用来验证模拟器可用性。 2. LLMAgent调用兼容 OpenAI 的接口让模型输出 JSON 动作与理由。 import json import os class AgentBase: def __init__(self, name): self.name name def decide(self, state, role): raise NotImplementedError class RuleAgent(AgentBase): 简单规则 - 如果对方距离较近优先攻击 - 如果自己血量低适当后退 - 如果体力不足idle 回复 - 其他情况前进。 def __init__(self, namerule_agent, distance_threshold2.6): super().__init__(name) self.distance_threshold distance_threshold def decide(self, state, role): me state[role] op state[left if role right else right] distance abs(me[x] - op[x]) if me[hp] 30 and distance 5: return {action: retreat, reason: 血量低暂时后撤} if me[stamina] 10: return {action: idle, reason: 体力不足等待恢复} if distance self.distance_threshold: return {action: attack, reason: 对方在攻击范围内尝试挥砍} if distance 8: return {action: advance, reason: 距离较远主动接近} return {action: block, reason: 保持防守姿态观察对手} class LLMAgent(AgentBase): 将当前战局序列化为结构化状态交给大模型决策。 def __init__(self, namellm_agent, model, api_keyNone, base_urlNone, temperature0.2): super().__init__(name) self.model model self.temperature temperature self.api_key api_key or os.getenv(LLM_API_KEY) self.base_url base_url or os.getenv(LLM_BASE_URL) self.client None try: from openai import OpenAI self.client OpenAI(api_keyself.api_key, base_urlself.base_url) except Exception as e: print(f[warning] OpenAI SDK 不可用请运行 pip install openai。错误{e}) def build_prompt(self, state, role): me state[role] op state[left if role right else right] return f 你是一个 2D 横版剑术对战游戏中的剑客你的角色是 {role}。 当前战局状态 - 时间{state[time]} - 你的信息位置{me[x]}速度{me[vx]}血量{me[hp]}体力{me[stamina]}冷却{me[attack_cd]}防守中{me[blocking]} - 对手信息位置{op[x]}速度{op[vx]}血量{op[hp]}体力{op[stamina]}冷却{op[attack_cd]}防守中{op[blocking]} 请只输出一个 JSON结构如下 {{action: attack|block|advance|retreat|move_left|move_right|idle, reason: 简述你的决策思路}} 注意 - 你只能从上述动作中选择一个。 - 不需要输出额外解释不要输出 Markdown 代码块。 - 攻击有冷却和体力消耗格挡会减少后续伤害但也会消耗体力。 def decide(self, state, role): if self.client is None: raise RuntimeError(LLM 客户端未初始化请检查 openai 依赖和 API 配置) prompt self.build_prompt(state, role) try: resp self.client.chat.completions.create( modelself.model, messages[{role: user, content: prompt}], temperatureself.temperature, max_tokens80, ) content resp.choices[0].message.content.strip() content content.strip() if content.startswith(json): content content[4:].strip() data json.loads(content) return {action: data.get(action, idle), reason: data.get(reason, )} except Exception as e: print(f[error] {self.name} 决策失败{e}) return {action: idle, reason: f解析失败使用默认动作{e}}RuleAgent 是一个基线策略用来和 LLM Agent 做对照实验。它的逻辑很简单血量低就后撤体力不足就待机距离近就攻击距离远就前进其他情况格挡。虽然它不懂“推理”但可以作为验证整个模拟器是否能正常运行的“假想敌”。LLMAgent 是项目的主角。它的build_prompt方法把当前状态转成一段对话文本并在 Prompt 最后给定动作枚举。为了避免模型输出多余文字Prompt 里特意要求“只输出一个 JSON”。不过在实际调用中模型偶尔还是会输出 Markdown 代码块或前后缀文字因此decide方法里做了strip()和startswith(json) 的容错处理。如果模型输出无法解析成 JSONexcept分支会打印错误并返回idle相当于“这个回合发呆了一下”。这样的兜底机制很重要因为在真实运行时模型接口可能超时、返回空内容、或者返回一段解释文字而不是 JSON。盲评阶段评审者可以看到这类失败回合并据此判断模型的稳定性。4.3 对战主循环 battle.py接下来是对战主循环。它负责创建两个 Agent让它们轮流决策并把动作送入物理模拟器。# 文件路径llm-duel/battle.py 对战主循环创建两个 Agent循环决策并在世界中执行直到分出胜负或达到最大回合数。 import json import os from agent import RuleAgent, LLMAgent from physics import World def make_agent(name, config): if config[type] llm: return LLMAgent(namename, modelconfig.get(model), temperatureconfig.get(temperature, 0.2)) if config[type] rule: return RuleAgent(namename, distance_thresholdconfig.get(distance_threshold, 2.6)) raise ValueError(f未知 Agent 类型: {config[type]}) def run_battle(config, max_round120): seed config.get(seed, 42) world World(seedseed) agent_left make_agent(left_agent, config[left]) agent_right make_agent(right_agent, config[right]) for step in range(max_round): state world.get_state() action_left agent_left.decide(state, left) action_right agent_right.decide(state, right) world.step(action_left, action_right, dt0.1) s world.get_state() print(fstep{step:03d} time{s[time]:.1f} fL(hp{s[left][hp]:.1f},st{s[left][stamina]:.1f}) fR(hp{s[right][hp]:.1f},st{s[right][stamina]:.1f})) winner world.judge() if winner: print(f对战结束获胜方{winner}) break else: print(到达最大回合数按血量判定结果。) winner left if world.left.hp world.right.hp else right if world.right.hp world.left.hp else draw print(f判定结果{winner}) return { winner: winner, config: config, replay: world.replay(), } def main(): config { seed: 7, left: { type: llm, model: os.getenv(LLM_MODEL, gpt-4o-mini), }, right: { type: llm, model: os.getenv(LLM_MODEL, gpt-4o-mini), }, } result run_battle(config) with open(duel_result.json, w, encodingutf-8) as f: json.dump(result, f, ensure_asciiFalse, indent2) print(回放已保存到 duel_result.json) if __name__ __main__: main()这个脚本的逻辑非常直观。make_agent根据配置决定创建规则 Agent 还是 LLM Agent。run_battle中每一轮先读取当前状态然后让左右两个 Agent 独立决策再把决策交给world.step执行。这里有一个值得注意的细节两个 Agent 的决策是“基于同一份 state”而不是基于对方决策后的状态。也就是说它们在同一个时间切片上各自决策不存在先后手问题。这样可以避免后手 Agent 因为看到对手这回合动作而获得不公平优势让盲评更关注“在同样信息下谁决策更合理”。主循环默认最多跑 120 步。如果 120 步内没有分出胜负就按剩余血量判定胜负。实际运行时两个规则清晰的 LLM 通常会在几十步内打出结果但如果模型一直 idle 或者双方都只会格挡也可能打到最大回合。4.4 盲评脚本 blind_review.py对战结束后最重要的环节就是盲评。盲评脚本会读取回放文件随机决定左边角色显示为 A 还是 B从而让评审者无法通过“左右位置”推测模型身份。# 文件路径llm-duel/blind_review.py 盲评脚本读取对战回放文件不展示 Agent 真实名称只展示 A/B 阵营 让人类根据可解释理由和状态变化投票。 import json import random import sys def load_replay(pathduel_result.json): with open(path, encodingutf-8) as f: return json.load(f) def shuffle_sides(replay): 随机交换左右阵营的展示标签避免人类通过“左/右”形成固定偏好。 record replay[replay][record] flip random.random() 0.5 display [item for item in record] if flip: for item in display: state item[state] state[left], state[right] state[right], state[left] item[actions][left], item[actions][right] item[actions][right], item[actions][left] return display, flip def summarize_display(record, sample_every5): lines [] for i, item in enumerate(record): if i % sample_every ! 0: continue state item[state] a item[actions][left] b item[actions][right] lines.append( f回合 {i:03d}: A(hp{state[left][hp]:.1f},st{state[left][stamina]:.1f}) f行动{a[action]:10s} 理由{a.get(reason,)[:40]} | fB(hp{state[right][hp]:.1f},st{state[right][stamina]:.1f}) f行动{b[action]:10s} 理由{b.get(reason,)[:40]} ) return \n.join(lines) def review(replay_pathduel_result.json): replay load_replay(replay_path) display, flip shuffle_sides(replay) print( 盲评模式A/B 阵营已经随机打乱 ) print(summarize_display(display)) print(\n请根据以下维度打分1-5) print(1. 策略合理性动作是否符合当前战况) print(2. 局势判断是否根据敌我距离/血量/体力做出调整) print(3. 攻防平衡进攻与防守节奏是否合理) print(4. 稳定性是否出现明显无效动作或频繁报错) print() score_a input(A 阵营得分) score_b input(B 阵营得分) vote input(你判断谁更擅长推理(A/B)) print(\n 盲评完成 ) print(fA 阵营得分{score_a}B 阵营得分{score_b}) print(f投票结果{vote}) if flip: print(注意A/B 阵营在回放中已被左右反转最终结果请结合原始配置解读。) return {scores: {A: score_a, B: score_b}, vote: vote, flipped: flip} if __name__ __main__: path sys.argv[1] if len(sys.argv) 1 else duel_result.json review(path)盲评脚本的核心函数是shuffle_sides。它读取回放记录以 50% 的概率交换左右阵营的展示顺序。交换时不仅交换状态中的left和right还交换两个 Agent 的动作记录这样打印出来的回放就是一个完整的“A vs B”视角而不是简单的字段互换。summarize_display会每隔几回合输出一次关键信息避免一次性打印 120 行让人看不过来。每一行包含 A 和 B 的血量、体力、动作和理由。评审者通过这些信息判断哪边更像在认真思考。4.5 运行与验证先把依赖装上pip install openai python-dotenv然后准备一个.env文件参考内容如下LLM_API_KEY你的APIKey LLM_BASE_URLhttps://api.example.com/v1 LLM_MODEL你的模型名称如果你只想先验证模拟器本身能不能跑可以临时把 battle.py 里的两个 Agent 都改成rule类型跑一局规则对战。命令如下python battle.py运行后控制台会输出每一步的状态。如果你配置好了 LLM 接口也可以直接运行两个 LLM 对战。等待对战结束后会生成duel_result.json接着运行盲评python blind_review.py duel_result.json盲评脚本会打印一份抽样后的回放然后提示你给 A/B 阵营打分并投票。5. 盲评系统人类投票规则设计5.1 为什么要双盲很多 AI 评测项目会忽略“评审者偏见”这个问题。如果评审者知道左边是某个知名大模型右边是某个开源小模型打分时很容易带着“大模型应该更聪明”的心理预期。这种预期未必是恶意的但它确实会干扰对动作本身的判断。双盲机制的核心是让评审者既不知道模型身份也不知道原始左右位置。我在盲评脚本里加入了随机的左右翻转每次评审时 A/B 对应的真实阵营都可能不同。这样即使评审者潜意识里偏好“左边先手”或“右侧代表后手”也无法稳定地影响投票结果。对于多人盲评来说还可以进一步打乱评审顺序让同一条回放被不同评审者在不同翻转状态下评价。当然双盲不是万能的。如果某个模型在回放里频繁输出非法动作或者总是复读同一个理由评审者仍然能通过行为特征猜出它可能是某个模型。盲评的核心价值是降低偏见而不是完全消除身份泄露。在设计实验时应该把“身份泄露风险”记录下来作为结果解释的一部分。5.2 回放与投票界面当前版本的盲评界面是 CLI 模式适合本地实验。你可以在summarize_display里调整sample_every参数控制输出密度。如果样本量很大可以改成每隔 10 回合输出一次减少评审负担。如果你希望做更正式的盲评可以考虑写一个简单的 Web 页面用表格展示每个回合的状态、动作、理由并提供打分按钮。前端展示时也要注意不要展示模型名称、API 提供商、模型参数等敏感信息最好把原始回放文件中的name字段清空或替换成 A/B。评分维度方面我建议至少包含以下四项策略合理性动作是否和当前战况匹配比如距离远时是否选择前进或观察距离近时是否选择攻击或格挡。局势判断模型是否根据血量、体力、冷却时间的动态变化调整策略而不是死板地重复某个动作。攻防平衡是否既有进攻也有防守是否懂得在血线危险时后撤在体力和冷却有利时施压。稳定性是否频繁输出非法动作、解析失败、或者给出自相矛盾的理由。每个维度打 1 到 5 分最后综合投票。投票时不需要想太多凭直觉判断“如果让我作为队友我更希望谁上场”。5.3 如何避免盲评中的信息泄露最容易泄露身份的信息是模型返回的“理由”。不同模型在解释自己的动作时用词风格差异很大。有的模型喜欢说“根据当前情况我选择……”有的模型喜欢用“为了保持体力我决定……”这种句式。这些语言风格虽然不一定是模型能力的体现但评审者可能会通过这些特征联想出模型身份。一种缓解方法是清洗回放文本把理由中的模型特有措辞规范化。你可以预先定义一套“决策理由模板”要求 LLM 输出时按固定格式
返回列表