作者:亦盏、望宸

狼人杀一款经典的社交推理游戏,我刚毕业那会儿,玩狼人杀是聚餐时的保留节目,也留下了很多挺有意思的回忆:比如有的高手,如果第一晚没被狼人“杀掉”,那大家就会觉得他一定是狼人,白天必须自证清白。

但随着时间推移,凑齐一群人玩狼人杀变成了一件奢侈的事情。所以我用 AgentScope 做了这款可以随时开局的 AI 狼人杀。Agent 会像真人一样讨论、推理、投票,甚至学会"说谎",现在你不需要等人,随时都能和 AI 来一局,而且你还不一定能玩得过他。

游戏介绍

在 AgentScope 上和 AI 玩狼人杀

本视频基于 1.0.5 版本录制,后续更新代码可能会有部分调整优化。

快速开始

环境要求: Java 17+、Maven 3.6+、百炼 API Key(登录 https://bailian.console.aliyun.com/ 获取)

# 拉取项目源码
git clone https://github.com/agentscope-ai/agentscope-java

# 设置百炼 API Key
export DASHSCOPE_API_KEY=your_api_key

# 进入游戏目录并启动
cd agentscope-java/agentscope-examples/werewolf-hitl
git checkout release/1.0.5
mvn spring-boot:run

打开浏览器访问 http://localhost:8080,选择你想扮演的角色,点击开始游戏!


如何开发 AI 狼人杀?

当我真正动手用开发 AI 狼人杀时,发现这件事远没有"写几个 Prompt"那么简单。

首先,多个 AI Agent 需要持续地思考。要根据每一轮中各个玩家的发言和表现,分析他们的真实身份。同时像狼人、女巫、预言家、猎人这些角色,还需要在游戏的过程中根据实际情况,选择是否使用自己的技能——这些都不是一个简单的提示词就能搞定的。

其次,信息必须隔离。狼人杀最核心的乐趣就是"信息差":狼人知道队友是谁,但好人不知道;预言家查验的结果只有自己清楚。如果所有 Agent 共享同一份记忆,游戏就没法玩了。

然后,一群玩家轮流说话,Agent 能记住谁都说过什么吗? 大模型 API 只有 system/user/assistant 角色,没有"5号玩家说"这种概念。如果直接把对话历史丢给模型,它根本搞不清楚这一堆话分别是谁说的。

还有,Agent 的决策必须明确。投票环节,我们需要的是"投 3 号"这样明确的答案,而不是"我觉得 3 号有点可疑,可能是狼人吧"这种模糊表述。程序要能可靠地解析 Agent 的决策,不能靠正则表达式去匹配。

最后,还需要支持人类玩家。纯 AI Agent 对战固然有趣,但是人类玩家能够参与进来,才是一个正经的游戏。

针对这些问题,我们用 AgentScope 的 7 个核心能力逐一解决:ReActAgent 让每个 Agent 拥有持续思考的能力;MsgHub 实现了消息的自动广播和信息隔离;多智能体格式化器 让 Agent 能区分不同说话者;结构化输出 确保 Agent 的决策被可靠解析;多Agent编排 协调多个 Agent 按规则轮流行动;SSE 实时推送 让前端实时展示游戏进程;Human in the Loop 让人类玩家无缝加入对局。

接下来,我们将逐一拆解这些 AgentScope 的关键技术在狼人杀游戏中的应用。

ReActAgent:会持续思考的玩家

ReActAgent 是 AgentScope 提供的核心 Agent 实现,基于 ReAct(Reasoning + Acting) 范式,它能让 LLM 不只是"回答问题",而是"思考-行动-再思考":先分析当前局势,决定是否需要调用工具获取更多信息,执行工具后再继续推理,直到得出最终结论。

在狼人杀中,每个 AI 玩家都是一个 ReActAgent,它们能够记住之前的讨论内容,分析其他玩家的发言和投票的逻辑,根据自己的角色身份做出合理的决策。

创建一个 ReActAgent
ReActAgent werewolfAgent = ReActAgent.builder()
    .name("3号")                                           // 玩家名称
    .sysPrompt(prompts.getSystemPrompt(Role.WEREWOLF, "3号"))  // 角色提示词
    .model(model)                                              // LLM 模型
    .memory(new InMemoryMemory())                              // 对话记忆
    .build();

创建一个 AI 玩家只需要几行代码,主要包含四个核心配置:name 是玩家的标识,标识这个 ReActAgent 对应哪个玩家;sysPrompt 定义角色身份和行为策略;model 是底层的 LLM;memory 负责存储对话历史。

Prompt:角色提示词设计

Prompt 决定了 Agent 的"人设",在这里,狼人角色的提示词如下:

【角色身份】:你是3号,一名狼人。

【核心目标】:
- 隐藏你的狼人身份,生存到最后;通过发言误导好人,投票淘汰神职;与狼队友配合,在白天制造逻辑混乱。

【策略选择】:
  策略 A:悍跳狼(冒充预言家)
    - 第一轮起跳,给出虚假查验信息
    - 语气坚定自信,指责对手是"悍跳狼"

  策略 B:深水狼(伪装村民)
    - 发言精简,避免成为焦点
    - 像一个努力找狼的普通村民

不同角色的 Prompt 差异很大:狼人需要学会撒谎和配合,预言家、女巫、猎人需要懂得如何使用自己的技能,村民需要会分析逻辑找出破绽。

Memory:对话记忆管理

每个 Agent 都有独立的 Memory,记录它"听到"和"说过"的所有内容。当 Agent 调用 call() 生成回复时,会先从 Memory 中获取历史消息作为上下文发送给 LLM。在狼人杀中,Memory 的作用很关键:每轮讨论的发言都会写入 Memory,AI 可以回顾完整的讨论历史,判断谁的发言有逻辑漏洞、谁一直在带节奏。

ReActAgent 把 LLM 的能力封装成一个能持续思考和对话的玩家,他们能够记住之前的讨论内容,分析其他玩家的发言和投票的逻辑,在法官的引导下完成整局游戏。

MsgHub:实现 Agent 的对话和投票

狼人杀的精髓在于信息的不对称——狼人知道队友是谁,好人却一无所知。这对多 Agent 系统提出了挑战:白天的讨论、投票结果需要公开广播给所有人;但狼人夜晚的密谋、预言家的查验结果必须严格隔离,只有特定角色能看到。

如果手动管理每条消息的接收者,写一堆 if-else 判断"这条消息该发给谁",代码很快就会变成一团乱麻。AgentScope 的 MsgHub 用消息频道解决这个问题:不同场景使用不同的频道,每个频道有自己的参与者列表,消息只在频道内流转。狼人夜晚讨论用一个狼人频道,白天公开讨论用公共的频道,频道之间天然隔离。

还有一个细节:消息的广播时机也很讲究。讨论环节,玩家轮流发言,每个人说完后,其他玩家需要立刻听到这段话,这样后面发言的人才能针对前面的内容进行分析和回应——这需要实时广播。但投票环节不一样,如果每个人投完票就立刻公布,后面的人可能会跟票。所以投票内容需要先收集起来,等所有人都投完后再统一公布。MsgHub 同时支持这两种模式:自动广播用于讨论,手动广播用于投票

MsgHub 使用示例
// 讨论阶段:开启自动广播
try (MsgHub discussionHub = MsgHub.builder()
        .name("DayDiscussion")
        .participants(alivePlayers.stream()
            .map(Player::getAgent).toArray(AgentBase[]::new))
        .announcement(nightResultMsg)
        .enableAutoBroadcast(true)   // 自动广播
        .build()) {
    
    discussionHub.enter().block();
    
    for (Player player : alivePlayers) {
        // 每个玩家发言后,自动广播给其他所有玩家
        player.getAgent().call().block();
    }
}

// 投票阶段:关闭自动广播
votingHub.setAutoBroadcast(false);   // 先关闭自动广播
List<Msg> votes = new ArrayList<>();
for (Player player : alivePlayers) {
    Msg vote = player.getAgent().call(votingPrompt, VoteModel.class).block();
    votes.add(vote);
}
// 收集完所有投票后,统一广播结果
votingHub.broadcast(votes).block();

讨论阶段开启自动广播,Agent 的 call() 返回后立刻推送给其他参与者;投票阶段关闭自动广播,等收集完所有票后通过 broadcast() 统一公布。这两种模式背后,其实对应了 Agent 的两种消息接收方式:

  • 讨论阶段:每个玩家调用 call() 主动发言,MsgHub 自动把这条消息广播给其他玩家。其他玩家通过 observe() 方法"听到"这条消息——消息写入他们的 Memory,但不需要立刻回复。等轮到他们发言时,再调用 call() 基于 Memory 中的所有历史进行分析和回应。
  • 投票阶段:关闭自动广播后,每个玩家的投票不会立刻被其他人知道。等所有人都投完,再通过 broadcast() 一次性把所有投票结果发给大家。这时候调用的也是 observe(),玩家只需要知道结果,不需要回复。
MsgHub 核心原理

MsgHub 基于发布-订阅模式实现多智能体间的消息自动广播:

在这里插入图片描述

基于 MsgHub,我们用自动广播解决了讨论环节的实时同步,用手动广播解决了投票环节的统一公布,用多个 MsgHub 实现了狼人密谋、预言家查验、女巫决策等场景的信息隔离。

MultiAgentFormatter:让 Agent 理解多人对话

多个玩家轮流发言,每个人都有自己的立场和策略。当 7 号玩家需要分析局势时,他必须清楚地知道:刚才那句"我是预言家"是 3 号说的,而"他是悍跳狼"是 5 号说的。只有分清楚谁说了什么,才能进行逻辑推理。

大多数 LLM API 只支持** systemuserassistant **三种角色,没有"5号玩家"或"7号玩家"这种概念。如果我们把 Agent 的发言都以 user 角色发送给模型,模型看到的是这样的

user: 我是预言家,昨晚验了5号,查杀!
user: 我才是真预言家,3号是悍跳狼!
user: 作为5号的金水,我选择站5号。

三条消息,三个 user,模型根本分不清这是同一个人说了三句话,还是三个人各说了一句。逻辑推理无从谈起。

引入 AgentScope 的 MultiAgentFormatter 后, Agent 便能自动理解多人发言对话,发送给 LLM 的消息会被自动格式化成这样:

user: 
# Conversation History
The content between <history></history> tags contains your conversation history

<history>
3号: 我是预言家,昨晚验了5号,查杀!
5号: 我才是真预言家,3号是悍跳狼!
7号: 作为5号的金水,我选择站5号。
</history>
现在轮到你发言,请分析场上局势。

所有对话历史被合并成一条 user 消息,每条发言前面都带上了说话者的名字。LLM 一眼就能看出:3 号说自己是预言家并查杀了 5 号,5 号反驳说 3 号才是悍跳狼,7 号选择站 5 号。逻辑链条清清楚楚。

接入 MultiAgentFormatter

MultiAgentFormatter 的使用非常简单,只需在创建模型时指定格式化器

DashScopeChatModel model = DashScopeChatModel.builder()
    .apiKey(apiKey)
    .modelName("qwen3-plus")
    .formatter(new DashScopeMultiAgentFormatter())  // 一行代码搞定
    .build();

配置完成后,所有通过这个模型发送的请求都会自动进行多人对话格式化,不需要在业务代码中手动处理。AgentScope 为通义千问、OpenAI GPT、Claude、Gemini 等主流模型都提供了对应的 Formatter。

MultiAgentFormatter 实现原理

格式化器的工作分为两个关键步骤:消息标记消息合并

消息标记:每个 Agent 在创建时都有一个名字(如 "3号")。当 Agent 生成回复时,框架会自动把 Agent 的名字设置到消息的 name 字段:

// 创建 Agent 时设置名字
ReActAgent.builder()
    .name("3号")   // ← 这个名字会自动绑定到消息
    .model(model)
    .build();

// Agent 生成回复时,框架内部自动执行:
Msg responseMsg = Msg.builder()
    .name(agent.getName())  // ← "3号"
    .role(MsgRole.ASSISTANT)
    .content("我是预言家,昨晚验了5号,查杀!")
    .build();

这样,Memory 中存储的每条消息都带有发言者的标识,格式化器只需读取 msg.getName() 就知道是谁说的。

消息合并: 发送给 LLM API 之前,格式化器会把 Memory 中的多条消息合并成一条带标签的历史记录,Memory 中的原始消息如下,每条都是独立存储,带有 name 字段。

Msg(name="3号", content="我是预言家,昨晚验了5号,查杀!")
Msg(name="5号", content="我才是真预言家,3号是悍跳狼!")
Msg(name="7号", content="作为5号的金水,我选择站5号。")
...(可能有几十条)

经过 MultiAgentFormatter.format() 处理后发送给 LLM API 的消息只有 2 条:

[0] role: "system"
    content: "你是3号玩家,一名狼人..."

[1] role: "user"
    content: 
      "<history>
       3号: 我是预言家,昨晚验了5号,查杀!
       5号: 我才是真预言家,3号是悍跳狼!
       7号: 作为5号的金水,我选择站5号。
       </history>"

前文我们提到 LLM API 的角色只有三种,如果 9 个人讨论 2 轮就是 18 条 user 消息,模型很难理清楚这些消息之间的关系。合并成一条后,所有对话历史作为一个整体呈现,而且都是 name: content 这种统一的格式,LLM 能够清晰地理解完整的讨论脉络。

只需要在创建 ReActAgent 所使用的 Model 对象时,传入一个 MultiAgentFormatter,框架就会自动完成消息标记和合并,让 LLM 在只有三种角色的限制下,也能理解多人的多方对话。开发者不需要关心格式化的细节,专注于业务逻辑即可。

Structured Output:让 Agent 做出明确的决策

狼人杀的每个关键节点,都需要玩家做出明确的决策:投票选谁出局?预言家查验哪个玩家?女巫要不要用解药?这些决策必须是确定的——“投 3 号"或者"查 5 号”,不能是"我觉得 3 号和 5 号都挺可疑的"这种模糊表达。

但 LLM 天生就是自由文本的生成者。你问它"你要投谁",它可能回答"综合各方面分析,我认为那个一直沉默的玩家最可疑"——说了一堆,就是没告诉你具体投几号。就算你在 Prompt 里强调"请直接回复玩家编号",它有时候还是会夹带私货,输出"3号(因为他的发言逻辑矛盾)"这种格式。程序要解析这些五花八门的回复,简直是噩梦。

AgentScope 的结构化输出(Structured Output) 彻底解决了这个问题。你只需要定义一个 Java 类描述期望的数据结构,框架会自动约束 LLM 严格按照这个格式输出,返回的结果直接就是 Java 对象,不需要任何解析代码。

Structured Output基本用法

白天投票的示例

// 1. 定义投票模型
public class VoteModel {
    public String targetPlayer;  // 投票目标
    public String reason;        // 投票理由
}

// 2. 使用结构化输出调用 Agent
Msg vote = player.getAgent()
    .call(votingPrompt, VoteModel.class)  // 指定输出类型
    .block();

// 3. 直接获取类型安全的数据
VoteModel voteData = vote.getStructuredData(VoteModel.class);
System.out.println(voteData.targetPlayer);  // "5号"
System.out.println(voteData.reason);        // "他的逻辑有明显漏洞"
Structured Output实现原理

结构化输出基于 Function Calling 机制实现,通过将 Java 类转换为工具定义,引导 LLM 通过调用工具来生成符合格式的响应。

框架将 Java 类(如 VoteModel)转换为 JSON Schema,注册为临时工具 generate_response,让 LLM 通过调用该工具生成符合格式的响应。响应会被自动验证并转换为 Java 对象,失败则提示重试,最后清理临时工具。

Structured Output优势
  • 类型安全:返回结果直接是 Java 对象,IDE 自动补全、编译时检查,不用担心拼写错误或类型不匹配。
  • 格式统一:模型被强制按照 JSON Schema 输出,不会出现"有时候是 target,有时候是 vote"的混乱情况。
  • 自动重试:如果模型输出不符合格式,框架会自动提示模型修正并重试,不需要手动处理。
  • 开发高效:定义一个 POJO 类就搞定,投票、查验、救人等决策点复用同一套机制,不用每个地方都写解析代码。

多 Agent 编排:游戏流程控制

有了 ReActAgent、MsgHub、MultiAgentFormatter 和 Structured Output 输出这些能力,接下来的问题是:如何把它们串起来,让多个 Agent 按照狼人杀的规则有序地执行?

一局狼人杀的流程可以概括为:夜晚阶段 → 白天讨论 → 投票放逐,然后判断胜负,未结束则进入下一轮夜晚。每个阶段内部又有更细的流程,这里的每个流程节点都对应着前文介绍过的技术实现:

夜晚阶段 - 狼人讨论:创建一个只有狼人参与的 MsgHub,开启自动广播。狼人们在这个"私密频道"里讨论,好人完全看不到。最后通过结构化输出收集每个狼人的投票,统计出击杀目标。

夜晚阶段 - 神职行动:预言家、女巫、猎人的行动是私密的,不需要 MsgHub,直接单独调用对应的 Agent。查验结果、救人决定等信息直接写入该 Agent 的 Memory,其他人不知道。

白天讨论:创建所有存活玩家参与的 MsgHub,将昨天夜晚的结果加入到 MsgHub 中。开启自动广播后,玩家轮流发言,每个人说完后其他人立刻"听到"。

投票放逐:关闭自动广播,轮流调用每个 Agent ,通过结构化输出收集投票。等所有人投完后,统计结果并统一公布。

GameState 流转

GameState 是游戏全局状态,记录游戏的客观事实——谁是什么角色、谁还活着、当前是第几天、昨晚谁被杀了。这些信息由编排器维护,编排器在推进游戏时,也需要不断查询 GameState 来做出决策:游戏进行到第几轮了、现在是夜晚还是白天、该轮到谁行动了、游戏是应该结束还是继续。

GameState 通过 currentRound 追踪当前轮次,每次进入夜晚时递增。阶段的切换由编排器控制,在夜晚按固定顺序调度狼人讨论、预言家查验、女巫行动;在白天则遍历存活玩家列表,按座位顺序轮流发言和投票。每个阶段结束后,编排器会调用 checkWerewolvesWin() 和 checkVillagersWin() 进行胜负判定——狼人数量达到或超过好人则狼人获胜,狼人全部出局则好人获胜,否则游戏继续进入下一阶段。

游戏编排的核心流程包含:用 MsgHub 管理多 Agent 的消息广播和隔离,用结构化输出收集 Agent 的决策,用 GameState 维护游戏状态,用 Memory 保存每个 Agent 的对话上下文。编排器作为中心协调者,按照规则调度游戏有序运转。

SSE 实时推送:游戏进程的实时展示

前面几节讲清楚了 Agent 之间的消息流转和游戏流程编排,现在AI已经可以自己把狼人杀玩起来了,但是咱们还是什么也看不到。现在需要把游戏的实时进展推送到前端——让观战者能跟着剧情走,也为后面的人机对战做好准备。

我们使用 Server-Sent Events (SSE) 实现实时推送:服务器主动把事件推给前端,不需要轮询,发言和投票结果都能第一时间送达。

透出游戏事件

要把游戏推送到前端,首先需要一个中间层来收集游戏过程中产生的各类事件。GameEventEmitter 承担了这个角色:游戏编排器在每个重要节点(玩家发言、阶段切换、投票结果、玩家淘汰等)调用它的 emit 方法透出事件。

为了让观战者能够沉浸式地观战和享受自己推理的乐趣,我们设计了两种视角:

  • 玩家视角:实时推送,但会根据参与者的角色过滤掉不该看到的信息。"村民视角"就只能看到公开信息,“狼人视角"就能看到狼人夜间讨论,观战时默认使用"村民视角”。
  • 上帝视角:保存所有事件,不做任何过滤,游戏结束后用于复盘。
public class GameEventEmitter {
    // 玩家视角:实时推送(根据角色过滤)
    private final Sinks.Many<GameEvent> playerSink = 
        Sinks.many().multicast().onBackpressureBuffer();
    
    // 上帝视角:保存所有事件(用于复盘)
    private final List<GameEvent> godViewHistory = new ArrayList<>();
    
    private void emit(GameEvent event, EventVisibility visibility) {
        // 上帝视角:无条件保存
        godViewHistory.add(event);
        
        // 玩家视角:根据可见性过滤
        if (visibility.isVisibleTo(humanPlayerRole)) {
            playerSink.tryEmitNext(event);
        }
    }
}

游戏进行时,观战者通过 playerSink 实时收到过滤后的事件流;游戏结束后,可以通过 /api/game/replay 接口获取完整的 godViewHistory,回看所有隐藏的细节——“原来 5 号真的是预言家!”

服务端提供事件接口

有了事件发射器,接下来要让前端能接收这些事件。Spring WebFlux 对 SSE 有原生支持:Controller 返回一个 Flux<ServerSentEvent>,浏览器请求这个端点后,HTTP 连接会保持打开,服务器可以随时往里面推送数据。

@PostMapping(value = "/start", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<GameEvent>> startGame(...) {
    GameEventEmitter emitter = new GameEventEmitter();
    WerewolfWebGame game = new WerewolfWebGame(emitter, ...);
    
    // 游戏在后台异步执行,不阻塞 SSE 连接
    Mono.fromRunnable(() -> game.start())
        .subscribeOn(Schedulers.boundedElastic())
        .subscribe();
    
    // 把事件发射器的输出转成 SSE 格式
    return emitter.getPlayerStream()
        .map(event -> ServerSentEvent.<GameEvent>builder()
            .event(event.getType().name().toLowerCase())
            .data(event)
            .build());
}

这段代码做了两件事:一是在后台线程启动游戏逻辑,二是把 emitter 的事件流转换成 SSE 格式返回。游戏逻辑每 emit 一个事件,浏览器就能实时收到。

前端实时渲染游戏进程

最后一步是前端订阅这个 SSE 流。浏览器原生的 EventSource API 让这变得非常简单——建立连接后,为每种事件类型注册处理函数,收到事件就更新 UI。

const eventSource = new EventSource('/api/game/start');

// 有人发言了 → 追加到聊天记录
eventSource.addEventListener('player_speak', (e) => {
    const event = JSON.parse(e.data);
    appendMessage(event.data.player, event.data.content);
});

// 阶段切换了 → 更新顶部状态栏
eventSource.addEventListener('phase_change', (e) => {
    const event = JSON.parse(e.data);
    updatePhase(event.data.round, event.data.phase);
});

// 游戏结束了 → 显示胜负结果,关闭连接
eventSource.addEventListener('game_end', (e) => {
    const event = JSON.parse(e.data);
    showGameResult(event.data.winner);
    eventSource.close();
});

整个流程非常简洁:后端游戏逻辑产生事件 → GameEventEmitter 过滤并发射 → Controller 转成 SSE 格式 → 浏览器实时接收并渲染。不需要轮询,不需要 WebSocket 的复杂握手,一条 HTTP 长连接搞定一切。

Human in the Loop:人机对战

前面通过 SSE 把游戏进程推送到了前端,观战模式可以实时看到 AI 之间的博弈。但是光看肯定不过瘾,人类玩家能够参与进来,才是一个正经的游戏。

游戏编排器调用 agent.call() 时,可以同步拿到结果继续执行。AI 玩家可以及时返回,但人类玩家需要等待——等用户在浏览器上思考、输入、提交。如何让同步的游戏流程"暂停"在人类玩家这里,等到输入后再继续?AgentScope 提供的 UserAgent 解决了这个问题。它和 ReActAgent 实现同一个接口,游戏编排器不需要区分当前是人还是 AI,以同样的方式调用即可。而具体如何获取人类输入,则可以通过注入不同的 UserInputBase 实现来定制。

UserAgent:与 ReActAgent 接口一致

对于游戏编排器来说,人类玩家和 AI 玩家没有区别——都是调用 agent.call() 拿到回复,区别只在于创建时的配置:

// AI 玩家 - 使用 ReActAgent,由 LLM 生成回复
agent = ReActAgent.builder()
    .name("3号")
    .sysPrompt(prompts.getSystemPrompt(Role.WEREWOLF, "3号"))
    .model(model)
    .memory(new InMemoryMemory())
    .build();

// 人类玩家 - 使用 UserAgent,由用户输入回复
agent = UserAgent.builder()
    .name("1号玩家")
    .inputMethod(webUserInput)  // 注入 Web 输入源
    .build();

这种设计的好处是:游戏编排器的代码完全不需要改动,只需要在初始化时把某个位置的 ReActAgent 换成 UserAgent 就能实现人机对战。

WebUserInput:浏览器与游戏的桥梁

WebUserInput 是连接浏览器和游戏逻辑的关键组件。它的核心机制是使用 Reactor 的 Sinks.One 实现异步等待:

public Mono<String> waitForInput(String inputType, String prompt) {
    // 1. 创建一个 Sink,类似 CompletableFuture
    Sinks.One<String> inputSink = Sinks.one();
    pendingInputs.put(inputType, inputSink);
    
    // 2. 通过 SSE 通知前端:现在轮到你了
    emitter.emitWaitUserInput(inputType, prompt);
    
    // 3. 返回 Mono,游戏线程会在这里等待
    return inputSink.asMono();
}

public void submitInput(String inputType, String content) {
    // 用户提交后,找到对应的 Sink 并发出值
    Sinks.One<String> sink = pendingInputs.remove(inputType);
    if (sink != null) {
        sink.tryEmitValue(content);  // 解除等待,游戏继续
    }
}

当游戏轮到人类玩家时,waitForInput() 会通过 SSE 发送一个 WAIT_USER_INPUT 事件给前端,然后返回一个 Mono 让游戏线程等待。用户在浏览器上输入并提交后,前端调用 /api/game/input 接口,Controller 调用 submitInput() 发出值,游戏线程被唤醒继续执行。

完整流程

整个人机交互的数据流可以概括为:游戏等待 → SSE 通知前端 → 用户输入 → REST 提交 → 游戏继续。
在这里插入图片描述

基于这套机制,你可以选择任意角色加入游戏:扮演预言家带领好人阵营推理,或者混入狼人阵营,也可以随机一个角色。现在你不需要等待人齐,随时打开就能跟 AI 玩一局。


项目整体回顾

最后我们再来整体回顾一下项目,我们面临六个核心挑战:如何让 Agent 持续思考而非一次性回答、如何在公开讨论和私密密谋之间实现信息隔离、如何让 Agent 理解多人对话中谁说了什么、如何确保 Agent 的决策能被程序可靠解析、如何让人类玩家与 AI 无缝交互、以及如何将游戏进程实时展示给用户。

针对这些挑战,我们用 AgentScope 的核心能力逐一解决:ReActAgent 让每个 Agent 拥有持续思考的能力;MsgHub 实现了消息的自动广播和信息隔离;多智能体格式化器 让 Agent 能区分不同说话者;结构化输出 确保 Agent 的决策被可靠解析;UserAgent 让人类玩家无缝加入对局;流式输出 配合 SSE 实时推送让前端实时展示游戏进程。

在此基础上,我们还需要处理游戏特有的编排运维与交互问题:GameState 维护游戏全局状态,支撑整个游戏编排逻辑;AgentRun 提供 Agent 运行时环境;Reactor Sink 实现异步等待,让 WebFlux 应用能够优雅地等待用户输入。

What’s Next?

🎮 多人对战,更加有趣

目前的实现是单人与 AI 对战,但狼人杀的魅力在于玩家之间的心理博弈。想象一下:你和朋友们一起加入游戏,在白天讨论中互相试探、相互表演,而 AI 玩家填补剩余的席位,让你们不需要凑齐人数也能开局。多人对战模式将带来更真实的社交推理体验——毕竟,骗自己的朋友可比骗 AI 有意思多了。

🎙️ 全模态交互,身临其境

纯文本的对话虽然高效,但少了几分沉浸感。AgentScope Python 版本狼人杀[https://github.com/agentscope-ai/agentscope/tree/main/examples/game/werewolves]已经实现了 TTS 和 实时全模态的支持。下一步我们计划在 Java 版本也引入语音交互:你可以直接用语音发言,AI 玩家也会用语音回复——御姐音的女巫冷静分析局势,萝莉音的村民楚楚可怜地自证清白,低音炮的狼人在夜晚密谋时压低嗓音……每个角色都有专属的声线,让游戏从"看对话"变成"听对话",仿佛真的围坐在一起玩桌游。

🤝 欢迎参与社区贡献

这个项目是 AgentScope Java 的示例应用,我们非常欢迎社区的参与和贡献!无论是修复 Bug、优化体验、添加新角色(白痴、守卫、丘比特……),还是实现上面提到的多人对战和语音功能,都期待你的加入。你可以通过提交 Issue 反馈问题,或者直接提交 Pull Request 贡献代码。让我们一起把这个项目做得更好!欢迎加入AgentScope 钉钉交流群,群号: 146730017349。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐