
那段时间我在琢磨一个很有意思的问题AI 角色扮演里的“多角色同场”到底怎么做才不会显得像几个临时拼凑的假人我做了一个很业余的尝试在 AI 酒馆里搭了一场三人同框的戏然后给这个场面加了一套“群聊调度”机制。三个角色真的能接上对方的台词也会对同一个突发事件做出不同反应。这个项目后来成了我参加 B 站 AI 创造公开赛的作品但过程中反复验证出来的那套思路比最终跑出来的几段文本更值得写下来。先说一个核心判断如果你只是把几个不同人设的角色塞进同一个聊天框那不叫群聊那叫一场“多人抢麦事故”。真正让三个角色互相接上戏的不是模型多聪明而是你给它建立的那套调度规则、共享记忆和上下文隔离机制。这篇文章想拆解的就是这套看起来不复杂但少一个环节都会崩的工程结构。1. 为什么多角色同场常常聊着聊着就“断片”1.1 三个模型放在一起不等于三个角色在一起我在开发初期犯过一个很常见的错误把三个不同人设的角色配置到同一个前端里然后让它们依次发言。看起来很简单A 说完给 BB 说完给 CC 说完再回到 A。结果第一次测试就出了问题。角色 A 在上一轮已经被一把袖剑抵住了脖子等到角色 C 发言时它完全忘了这件事还站在店门口笑嘻嘻地跟路人打招呼。角色 B 上一句话才把桌上唯一一把武器扔出窗外下一轮又从腰间掏出一把同样的武器。这就是多角色场景最常见的“断片”问题。它的根源不是模型智商不够而是你让每个角色分别跟同一个“对话框”对话却没有把其他人的行为转写成它能看到的事实。单角色聊天时模型只需要关注“用户说了什么角色回应什么”整条上下文的焦点非常集中。一旦引入多人同场模型面对的是一个更复杂的局面它既要维持自己的人设又要理解别人的行为还必须判断自己现在有没有必要说话、说什么、会不会跟上一个角色的动作冲突。如果这些信息没有被显式整理模型就只能靠猜。猜出来的结果自然不稳定。1.2 表面问题是“接不上话”实际问题是上下文没有同步当时我把这个现象归结为“模型上下文不够长”后来发现不对。真正的原因是一个时序问题三个人物的对话发生在同一条时间线上但它们的每次生成都是基于不同的上下文快照。A 发言之前看到的是上一轮的结果B 发言之前看到的可能是 A 还没发言时的旧状态。我见过许多项目会在这一层直接用“伪多轮”解决A 生成完文本把 A 的输出追加到共享记录里再让 B 基于整段记录生成。这个方案方向是对的但如果你只做“追加文本”角色依然会漏信息。因为一段完整的群聊文本里不光有台词还有动作、表情、内心活动、旁观者反应。这些都是模型判断下一步行为的重要依据。直接塞一整段手记模型很难分清哪一句是它应该回应的、哪一句只是环境噪音。用我后来的说法这属于“公共事件没有结构化”。1.3 我所谓“接戏”指的是三个连续指标测试时我给自己定了三个很朴素的观察指标动作连续A 上一轮打破了一件古董B 或 C 在接下来的对话里能提到碎片或赔偿。事实一致角色手里的道具、所在位置、身上伤势不会突然消失或转移。说话权合理不会出现没人说话也不会出现三个人同时抢着讲一件跟自己无关的事。这三个指标看起来特别基础但把它们同时稳住需要做大量的前置工作。后面所有模块几乎都是围绕这三个指标搭起来的。2. 群聊不是“多了一个按钮”而是多了一套行为分层2.1 聊、演、控需要三种不同的指令结构我做群聊时学到最重要的经验是要把角色的输出行为分层看待。第一个层面是“聊”。对应的是普通对话角色针对上一个发言者的话题进行回应语气符合人设不需要推动剧情。第二个层面是“演”。对应动作描写、内心活动和场景决策。角色会表现出“我现在要做什么”而不是只说“我理解你的意思”。第三个层面是“控”。这一层通常不交给角色角色自己而由系统或用户完成。比如剧情快跑偏时停止并修正或者决定此刻应该让谁优先发言。许多人做群聊失败是因为把这三个层面混在一段提示里。角色既要聊天又要演出还要负责控场任务一多模型就会偏向最简单的那种回应方式比如不停重复别人的观点或者把所有角色都写成同一副口吻。我把这套结构写到角色卡之后效果立刻不一样了。角色不是每轮都要“演”也不是每轮都要“聊”。它会根据当前事件和自己的状态选择一个最合适的表达层。层就是人设的基本盘。2.2 最小角色卡结构人设、愿望、当前状态一个能稳定参与群聊的角色卡需要包含三个部分缺一个都会影响同场表现。第一部分是人设最好做到能够回答“我是谁、我和其他角色是什么关系、我的说话习惯是什么”这三件事。第二部分是行为愿望也就是角色当前最想做或最不想做的事。第三部分是当前状态包括位置、健康、手持物、记忆里的关键事件。人设决定角色怎么说话愿望决定它为什么要接话当前状态决定它接话的内容边界。我用的是一个很朴素的结构类似这样角色名: 池鱼 人设: 年轻的女术士语速快耐性差对旧王朝文献充满好奇 愿望: 搞清楚杂货铺地下室那扇门后藏着什么 当前状态: 地点: 老街杂货铺 健康: 左肩轻伤 手持物: 一本旧地图册 最近事件: 听见门外传来马蹄声这套结构的好处是需要更新角色状态时只用替换“当前状态”里的字段不必重写整段角色卡。2.3 群聊真正考验的是谁能拿到下一轮发言权很多 AI 前端都支持“一键创建多个角色”但聊起来还是像各说各话原因是缺少一个核心机制发言权分配。现实中的群聊不是每轮每个人都必须说话而是根据上一个事件选择最有可能反应的人然后让其他角色处于“可以听到但暂时不抢话”的状态。我一开始总是让 A、B、C 轮流说话结果非常生硬。后来改成“按事件相关度决定谁下一个发言”情况好了很多。所谓事件相关度就是发生了一个动作或一句台词之后谁的最核心愿望被触动谁最有动机回应。优先让动机最强的人说话能让整场对话保持紧凑。这不是一个特别高深的技术但它是一个需要显式实现的逻辑。3. 公共记忆分三层记不住才不会乱演3.1 第一层世界固定记忆所有角色共享多人同场最难管理的是记忆因为你不能无限延长每个角色的上下文。我的做法是把记忆切成三层每一层的保存粒度不同。第一层是世界固定记忆适合放置地点设定、年代背景、当前场景里的固定物件和地理关系。这层几乎是静态的在长对话中不需要频繁改写。举例来说如果群聊发生在一条老街的杂货铺里那么我会把这层信息写成【世界背景】 地点是临海旧城的青苔街白天安静傍晚会有渔船归来。 杂货铺位于街角铺面临街后面连着一个小院院内有一扇锁死的铁门。 铁门钥匙失踪多年传闻和旧王朝的测绘队有关。这层记忆在每轮生成时都会被注入到各角色上下文中并且内容保持稳定。为什么不把它写进每个人的角色卡因为一旦后续需要调整世界设定只改一处缓存即可不必逐个角色去翻。3.2 第二层共享时间轴只记录“别人能看见”的事第二层是共享时间轴它记录最近发生、并且可以被所有角色感知到的事件。它的最小单位不是“人物台词”而是“事件”。台词是事件的一部分动作同样是事件的一部分。我通常用类似下面的格式保存时刻 1池鱼推开杂货铺后门木门发出吱呀声。 时刻 2老姜抬起手示意池鱼不要出声。 时刻 3门外传来马蹄声有人喊了一句“搜”把这些事件写成一个“公共可见列表”有一个好处每个角色在生成下一轮对话前看到的不是一大段混杂的小说而是一条干净的、按时间排序的事件流。这种格式化文本比自然语言片段更容易被模型当作事实使用。3.3 第三层私有记忆只给当前角色看见只共享事件还不够。角色还会产生一些不适合被其他人知道的想法比如怀疑、伪装、隐藏目的。如果这些内容也被写进公共事件就会出现“角色知道得太多”的戏剧事故导致推理环节消失。我的方案是把每个角色的私密判断拆出来放到私有记忆区。当某个角色发言时私有记忆才被注入它的上下文。私有记忆通常包含两条信息一是角色对当前事件的真实态度二是角色接下来如果要隐瞒应该隐瞒到什么程度。这样既保留了群聊的公共秩序又给每个角色留下了单独发挥的空间。3.4 为什么要做记忆隔离而不是把所有内容拼成一个大上下文有人可能会问既然大模型上下文窗口已经很大为什么不能把所有角色的历史对话拼在一起让模型自己去判断理论上可以实际发现有两个问题。第一上下文越长模型注意力越容易分散越难盯住当前最该回应的那条信息。第二不同角色的“信息边界”并不相同如果一个角色预知了自己不该知道的信息表演会很奇怪。这就像读剧本的人突然看到了另一个角色的独白知道太多后反而容易演得过火。把记忆分层本质上是在控制角色之间的信息差。这是群聊和单角色聊天最大的不同点单角色聊天追求信息越全越好群聊追求信息刚好够用。后来我把这套分层总结成一句话关键不是让每个角色都记住所有事而是让每个角色记住它应该知道的那部分。4. 最小工程实现一个能切换“导演视角”的群聊调度器4.1 先接一层调度而不是直接改前端我不想为了一个测试需求去改动完整的酒馆前端源码所以采用了一个更简单的方案在模型调用之前加一层中间的调度逻辑。这层逻辑负责判断谁该发言、需要注入哪些记忆、生成完后是否需要更新共享时间轴。如果你也打算复刻这套结构可以先从一个最小可运行版本开始不需要做太重的界面。用最朴素的控制台方式跑通逻辑再决定是否接回 UI。我用的是类似下面的伪代码结构。它不是一个可以直接粘贴的完整脚本但代表了调度器的主干。class ChatRoom: def __init__(self, members, world_memory, private_memories): self.members members self.world_memory world_memory self.public_timeline [] # 共享事件 self.private_memories private_memories # 每个角色私有信息 def build_context(self, member_id): context [] context.append(self.world_memory) context.append(self.format_timeline()) context.append(self.private_memories[member_id]) return \n.join(context) def decide_next_speaker(self, last_event, candidates): # 简化规则谁对 last_event 的“愿望关联度”最高谁先说话 return max( candidates, keylambda mid: self.relevance_score( mid, last_event ), )这个类负责的核心事情就是角色发言之前先给它拼一段“它此刻应该看到的上下文”。4.2 主流程不是轮流而是“事件触发 回应生成”在跑通第一版后我又把流程从“固定轮流”改成了下面这种判断当前场景里最值得注意的事件。从在场角色中选出最适合回应该事件的那个人。为该角色拼装上下文。让模型生成台词和动作。解析输出区分“台词”和“动作事件”。把新事件追加到共享时间轴。根据新事件判断下一位回应者。这个流程把“谁说话”从循环逻辑里抽离出来变成和“发生什么事”强相关。角色不再是为了说话而说话而是因为有事发生才说话。4.3 角色输出解析模型给的结果要拆成“可执行事件”一开始我试图让模型直接输出一段完整的小说式文本后来发现很难让其他角色有效读取。文本越像小说包含的信息量越杂调度器就越难判断其中哪些内容属于动作、哪些属于台词、哪些属于内心活动。所以我强制要求模型在一轮输出中先写动作再写台词用行内标记分隔。这么做不需要额外引入复杂框架也可以在构建上下文时自动过滤掉内容。一个简单的例子如下[动作] 池鱼倒退两步撞倒了货架上的旧瓷瓶。 [台词] “你刚才说测绘队当年在下面画过地图”解析层只需要按方括号把动作部分认出来再将动作转换为共享时间轴事件。台词部分则按普通对话保留。两者性质不同不能混为一谈。4.4 注入调度意图而不是让模型自由发挥在提示设计上我还在上下文开头加入了一个明显的“调度意图”。比如你现在是群聊中的参与者。 请根据最近公共事件判断作为“池鱼”你是否需要行动。 如果与你无关你可以选择保持沉默。不要小看这段提示它阻止了大量无效发言。没有这个提示时角色经常会对所有人的台词各个回应一遍导致一场对话里出现大量“嗯嗯”“原来如此”“你说得对”。加入调度意图之后角色的回应频率明显下降但每次回应的质量明显上升。5. 怎么验证“三个角色真的会互相接戏”5.1 设计三组不同的压力测试群聊调度器搭完不是能聊起来就万事大吉。我用三组相对极端的场景检验它到底有没有真正“接戏”。第一组是“信息传递测试”。让角色 A 说出一个具体道具和地点然后让 B 在之后提及这个信息观察 C 是否也能据此做出反应。第二组是“情绪冲突测试”。让 A 和 B 发生争吵C 在场但不属于任何一方。理想状态下C 应该保持相对中立或者至少不会突然站到某一方。第三组是“突发事件测试”。比如街上突然起火角色应该根据各自愿望做出不同选择而不是都想跑去救火。这三组测试分别对应信息同步、角色定位、意愿差异三个维度。只要它们都能稳定通过才算有了一点“团队演出”的样子。5.2 用日志与简单指标记录每一轮我建议每次跑测试时都导出完整的调度日志不要只看最后一段生成的剧情。日志里至少要有这样几项信息当前被选中的发言角色是谁。触发它发言的事件是哪一条。注入到它上下文里的公共时间轴长度是多少。它输出的动作是否被成功加入共享时间轴。下一轮被选中的角色是否合理。为了方便快速复盘可以用一个很简单的表格记录测试结果测试维度通过标准我观察到的现象信息传递关键道具和地点在 3 轮内被正确引用第一版失败角色常把“地图册”误写成“信”情绪冲突C 不被迫站队能保持旁观或自我行动加入行为愿望提示后效果明显好转突发事件三个角色行动方向不同且符合各自愿望稳定通过很多问题光看文本不容易发现但放到日志里一下子就能看出是哪一层出了问题。5.3 第一版翻车案例A 的台词出现在 B 的记忆里我记得最清楚的一次调试是输出日志里出现了很奇怪的现象明明这一轮只有角色 B 发言但它回应了一个还没发生的事件像是提前读到了 A 的未来动作。排查半天才确认问题出在共享时间轴更新时机太早。A 虽然没有发言但它上一轮留下的“潜在动作”还没被执行就被当成公共事件写进了 B 的上下文。结果 B 把“准备要发生的事”当成了“已经发生的事”来回应。这个案例教会我一件事调度器里的“共享记忆更新”必须严格发生在角色生成结束之后。任何尚未实际发生的事件都不应该进入公共时间轴。6. 有哪些边界和必须接受的代价6.1 它适合什么不适合什么这套方案归根结底是一个轻量级的创作实践适合用来做多角色剧情推演、角色扮演前端的玩法验证、小规模 AI agent 互动演示。但如果你需要的是严格意义上的“多智能体并行决策”也就是每个角色同时行动、相互影响、最后合并出一个统一世界状态那这套顺序调度的思路就不太合适了。顺序调度本身存在一个隐藏假设同一时间只能有一个人物发生关键行为。这在很多群像故事里并不成立。此外如果你的目标是在极低 token 成本下运行很强的群聊体验也需要提前想清楚。共享时间轴、私有记忆、多个角色分别生成意味着每个回合都会消耗比单角色聊天更多的 token。6.2 成本不只是 API 费用还有状态管理成本多数人刚开始做这类项目时会关注 API 费用后来发现真正吃时间的是状态管理。角色数量每增加一个不是简单加一条角色卡而是要更新公共时间轴、角色关系、信息边界和发言逻辑。三个角色是我测试下来很好控制的规模超过五个以后文本状态会变得非常混乱。如果你的目标是运营一个超大群聊我建议把“全局事件”按地图或场景切成多个独立房间而不是让所有角色共享同一条时间轴。这就像录一场综艺节目机位越多切换难度越大。与其把镜头架满全场不如先想清楚观众此刻该看谁。6.3 四个踩过的坑提前帮你排掉我会把踩坑经验收束成四个点方便你在复刻时对照自检。第一个坑直接把角色内心活动写进公共时间轴。这会让其他角色“未卜先知”破坏表演层次。正确做法是心里话留在私有记忆区。第二个坑把世界固定记忆随时间推进不断追加。长期跑下来世界背景会越来越长。正确做法是固定记忆只保留基础设定动态变化只更新到时间轴里。第三个坑要求每个角色每轮都必须发言。这会让表现显得机械且拥挤。正确做法是允许沉默沉默本身也是群聊的一部分。第四个坑生成完成后才做解析但解析失败时缺少补救路径。比如模型偶尔会输出一段没有任何标记的内容调度器会不知道该记录为动作还是台词。我后来加了一个简单策略解析失败就把它按台词处理而不是整轮作废。6.4 把它变成一个可持续迭代的最小模板这个项目最后能够成型一个重要原因是每一轮改动都保留了一套最小模板。角色卡只用统一的 Markdown 块维护公共时间轴用单一 JSON 结构保存调度器只保留一个生成入口。所有后续测试都通过这套模板复跑。如果你也想做类似的方向我的建议是不要一开始就去追求复杂的花式功能先用一个最小的产品闭环跑通“三个角色 一条时间轴 一个调度器”再逐步加入私密剧情、分支事件、随机事件和 UI。等基础框架稳定再谈更多角色的可能。最后这不是一个“谁模型更强”的问题回头复盘整个项目我最大的感受是多角色群聊的成败不取决于某个模型表达能力有多强而是取决于你有没有给这群角色建立足够清晰的“共同世界”。模型擅长的是语言。但“一场戏能不能接住”这件事靠的不是语言模型独自灵光一现而是一套工程系统在背后负责调度什么人知道什么、什么人先开口、什么事件被写进公共记录、什么信息只留给角色自己。我做的只是用最普通的方式在正式生成前多做了一步“状态整理”让角色们在同一个世界里互相看见而不是各自表演一套单口相声。下一位角色会不会真的接住上一句的悬念答案不在模型的超能力里而在你愿不愿意先把秩序和记忆认真地当成工程来处理。