AI酒馆群聊实现:多角色轮转调度与人设保持全解析
发布时间:2026/9/4 13:36:16 作者:尧图编辑部 阅读量:1,286

AI 酒馆类本地聊天工具大多数人玩的是“我 多个角色”轮流对话。但很多场景里我们需要的不只是用户和角色聊天而是几个 AI 角色之间产生互动甚至在你只给了一个开场之后它们自己就能主动把戏接下去。这次看到的方向正好是给这类工具加“群聊”能力三个角色在同一个上下文里互相接戏、补台词、推进剧情。在没有真实多人试戏环境的情况下这个功能很考验两件事上下文组织以及“下一个谁开口”的调度策略。说得更直白一点如果只是把所有角色丢进同一个 prompt回复很容易变成同一个说话风格角色人格互相污染如果做不到分轮调度输出又会变成“你说他说的书面语排队”。所以给 AI 酒馆加群聊不只是堆一个文本框而是要设计一套“多角色 多线程 上下文隔离”的调度机制。从比赛项目信息来看这个作品解决的问题很明确让本地部署、通过大模型接口驱动的 AI 角色在群聊中保持人设并互相接戏。这篇文章不展开对原作代码的复刻而是把它作为切入点讲清楚实现多角色群聊的关键设计、验证方法和常见坑。如果你正准备给自己的角色扮演前端加群聊或者想批量测试多个角色在同一故事线中的稳定性这篇可以收藏备用。1. 核心能力速览基于“AI酒馆 群聊 三个角色互相接戏”这个项目目标整个方案需要拆成以下能力维度能力项说明项目定位给 AI 角色扮演聊天工具增加群聊模式支持多角色同场景对话核心功能多角色同上下文参与剧情、角色轮次调度、跨角色接戏、人设保持典型交互形式用户给出“舞台”或“开场白”角色之间自动完成后续多轮互动技术基础兼容 OpenAI 格式的本地或云端大模型接口或使用本地模型加载框架启动方式可做成前端插件 / 独立服务 / 批处理脚本需要按现有工具结构接入是否支持 API可封装为独立调用接口便于测试和批量运行是否支持批量任务适合做剧情分支批量测试只要按不同开场白循环调用显存占用取决于模型规模消费级显卡本地运行 7B~14B 模型通常较紧张主要难点上下文长度控制、角色发言权分配、回复格式解析、人设一致性适合场景角色扮演、互动剧情生成、多人剧本草稿、AI 角色行为测试这里需要特别说明显存占用、启动速度等数字必须结合你最终选用的模型版本和推理引擎来测。不要在没有跑起来之前就认为某个 7B 模型在任何显卡上一定流畅。2. 适用场景与使用边界群聊式 AI 角色扮演最适用的场景有两类。第一类是互动剧情创作。让几个角色围绕同一个事件各自表达态度而不是用户一条一条催。比如开场是“三人小队发现任务目标消失”后面的悬念推进、互相怀疑、各自台词可以由角色自动接龙完成。这里用户扮演的是“导演”而不是“其中一位角色”是群聊和单聊最大的区别。第二类是角色行为测试。如果你在做角色卡或提示词工程你可能会反复测试角色是否按人设回答。单聊测试需要一个个角色手动跑群聊机制可以让多个角色在同一个场景中互相触发你能更快发现哪个人设不稳定或者哪段提示设计让角色说了不属于它的台词。使用边界同样需要重视。多角色群聊更适合“虚构故事创作”不建议用它做真实人物模拟尤其不能在没有授权的情况下构建与现实中的个人身份、声音、人脸相关的交互内容。涉及 AI 生成内容时务必在项目文档和展示中标注“AI 生成”确认不侵犯他人的姓名权、名誉权、肖像权和版权。不要把角色卡设计成引导模型输出违法违规内容的形态也不要让群聊变成批量生成不当内容的工具。合规使用的底线是授权清晰、用途透明、输出可追溯。3. 群聊功能的技术难点拆解在动手写代码之前先看三件核心难事。3.1 上下文长度单聊场景下一个用户一个 AI系统提示词和角色卡可以写得比较细致。群聊场景下三个角色意味着三张角色卡内容要同时塞进请求里系统提示词还要额外描述“这是群聊你们分别在什么时间点回应”。上下文长度会随参与角色数量线性增长。角色卡不一定所有内容都进主上下文。很多角色卡包含“性格标签 背景故事 说话风格 示例对话”如果全量注入三张角色卡很快把模型窗口占满。你需要在画布阶段做裁剪按当前剧情是否相关来决定哪些内容保留。3.2 回复格式解析群聊中模型输出不能是普通的一句话。系统要能识别一次输出对应哪一个角色、哪一段是对话正文、哪一段可能是旁白或动作描写。最常见的是用结构前缀规定输出[角色名]对话内容或者用 JSON 结构返回{ role: 林澈, reply: 任务目标消失得没有痕迹这不像失误。, action: 走到窗边脸色沉下来 }格式解析是群聊最容易出 bug 的地方。模型如果没严格遵守格式服务端就无法判断下一个该轮到谁导致整个流程卡住。所以不要只靠模型自觉要用 stop 词、JSON Schema 约束、输出过滤三层保障。3.3 发言人的选择“三个角色真的会互相接戏”核心在这里。顺序调度如果写死“A → B → C → A”互动会显得机械角色之间很难形成自然对话。因此需要引入发言权调度策略。4. 发言权调度的三种常见实现思路4.1 固定轮值最简单的做法。把角色列表排成队列每次按顺序轮流发言。这个方式不会重复、不会漏人适合剧情中需要每个人表明态度的正式场合。缺点是角色无法在别人发言中间插话剧情爆发力差。4.2 模型决定下一个发言者把当前完整对话历史交给模型做一步额外判断让模型输出下一个发言人的名字或一个简短的 action 词应用层根据模型给的名字选择请求哪个角色。这种模式更像真实群聊。当有角色抛出问题后模型会倾向于选择关系最紧张、最应该回应的角色。但调用次数要翻倍一次决定发言人一次生成发言内容。成本更高而且如果角色设定不清晰模型可能总是选择同一个角色当话事人其他角色沦为背景板。4.3 规则 随机权重这是目前团队做群聊时比较常用的折中方案。每个角色设置一个“响应优先级”和“主动说话概率”。没有明确语义指向时应用层用随机数从最近几个角色中选择下一个发言者同时给“被打断阈值”留出空间。举例A 角色说完一句带疑问语气的话系统可以优先从对话涉及对象中挑出一个而不是按固定顺序让 B 发言。从实际体验看想在“像真人吵架”和“不崩溃”之间取得平衡第三种的稳定性最好第二种的上限最高。如果你照示例做测试也更推荐先把固定轮值跑通再升级成模型决策。5. 环境准备与前置条件实现 AI 群聊需要准备两层环境第一层是大模型推理环境。使用 OpenAI 兼容接口即可本地可以通过模型加载工具启动一个 API 服务然后通过 HTTP 访问。模型本身要支持中文、遵循指令、具备多角色理解能力。更强的不建议从零训练直接选用国内外开源对话模型即可。第二层是群聊调度程序。你不需要把业务逻辑写进某个大型聊天框架内部更建议独立出一个轻量服务或者 Python 脚本负责读入角色配置集合。维护统一的群聊消息列表。调用大模型对话接口让指定角色发言。解析返回内容并追加到消息列表。按最大轮数或结束词停止。操作系统方面Windows、Linux、macOS 都可以。磁盘空间要预留模型文件 运行环境的体积。如果你要在本地加载一个 7B 量化模型建议至少准备 8GB 以上剩余显存如果是 CPU 推理内存可能占用较高速度也慢只适合前期功能测试。依赖建议pip install requests需要说明这只是通用运行依赖具体若使用其他语言需要按实际语言调整。核心不在安装多少库而在于你如何组织角色状态和消息记录。6. 群聊程序的核心数据结构与实现设计一个完整的群聊系统要先定义消息结构。如果用 Python 做最小示例可以这样设计from dataclasses import dataclass from typing import List dataclass class Character: id: str name: str persona: str speech_style: str dataclass class ChatMessage: sender: str content: str is_action: bool False # True 表示动作旁白而不是对白 dataclass class GroupSession: characters: List[Character] messages: List[ChatMessage] round_limit: int 10 max_context_length: int 3000请注意原文未给出该项目的内部代码。上面的结构是一种通用编排方案用来让你理解“群聊机制”本身要处理什么不是复刻原作。每个角色必须持有独立的“人设描述”字段。不要在消息历史里再重复塞入全部人设而是在构造请求时动态拼装但拼装后还要控制长度。6.1 统一调用大模型以一个兼容 OpenAI 的本地 API 为例通过requests发起对话请求。下面这段代码是“让指定角色发言”的核心函数整体思路是取出共享消息历史再拼上对应角色的人设。import requests API_URL http://127.0.0.1:8000/v1/chat/completions API_KEY not-needed def call_character(session: GroupSession, character: Character) - str: system_prompt ( f你是{character.name}。\n f人物设定{character.persona}\n f说话风格{character.speech_style}\n 当前是群聊场景请只以你扮演的角色身份发言。\n 输出格式[角色名] 发言内容不要输出其他注释。 ) payload_messages [{role: system, content: system_prompt}] # 取最近 N 条消息作为上下文避免超出长度限制 recent session.messages[-20:] for msg in recent: role user if msg.sender narrator: role system # 这里故意不把历史发言全部归入 assistant # 因为不同角色发言交替时会混淆角色归属。 if msg.is_action or msg.sender narrator: content f动作/旁白{msg.content} else: content f{msg.sender}说{msg.content} payload_messages.append({role: role, content: content}) response requests.post( API_URL, headers{Authorization: fBearer {API_KEY}}, json{ model: local-chat-model, messages: payload_messages, temperature: 0.8, max_tokens: 500, }, timeout120, ) data response.json() return data[choices][0][message][content].strip()6.2 将发言内容解析并写回消息模型返回的结果可能包含脏内容。举个例子模型可能输出林澈说目标丢失不一定代表任务失败。 王昭冷笑你说得倒轻巧。这种多角色混合输出无法直接正常处理必须建立解析器。最简单的做法是以字符下标扫描来找到角色名前缀。推荐用改进的 JSON 约束方式import json import re def parse_reply(raw_text: str, session: GroupSession) - dict | None: # 优先尝试 JSON 解析 text raw_text.strip() try: obj json.loads(text) return {sender: obj.get(role), content: obj.get(reply)} except Exception: pass # 回退到「角色名内容」解析 for c in session.characters: pattern rf^\[?{c.name}\]?\s*[:]\s*(.)$ matched re.match(pattern, text, re.S) if matched: return {sender: c.name, content: matched.group(1).strip()} # 解析失败就丢弃这段发言 return None如果模型经常不在输出开头写角色名可以在请求参数中加stop词指示模型在输出完第一个角色的发言后即停止比如加上句子边界标记。6.3 循环调度统一流程整体运行逻辑可以用下面这段伪代码写清楚def run_group_chat(session: GroupSession): current_index 0 round_no 0 while round_no session.round_limit: # 根据调度策略选角色这里用最简单的轮值 speaker session.characters[current_index % len(session.characters)] raw call_character(session, speaker) parsed parse_reply(raw, session) if parsed is None: round_no 1 continue session.messages.append(ChatMessage(senderparsed[sender], contentparsed[content])) print(f{parsed[sender]}: {parsed[content]}) current_index 1 round_no 1逻辑虽然简单但能形成一个稳定的三位角色对话循环。先把这条链路跑通之后再去加模型选择发言人的高级调度。7. 三个角色互相接戏的功能测试与效果验证要让“三个角色真的会互相接戏”这句话成立必须做可复用的测试。建议把测试分成五步。7.1 单角色人设测试先不要开群聊。分别用三个角色单独对话 5 轮检查角色语气、性格、知识背景是否正确。如果单角色没有做好群聊阶段一定会互相污染。7.2 双角色对戏测试开启两个角色给定一段小冲突场景观察两个角色是否能延续语境。比如场景A 在屋顶发现一张纸条B 突然出现怀疑 A 在隐瞒事情。A 和 B 说话时不能跳脱成第三方视角也不能出现一个角色顶着两个人的身份说话。7.3 三人群聊接龙测试进入三人群聊设定一个包含具体动作的开场白。示例输入旁白雨夜三人躲进废弃车站。电视忽然打开画面里出现的是他们各自走进候车室的画面。测试目标是连续跑 10 轮以上判断是否出现以下不良现象观察点成功标准角色身份清晰三人语言风格差异能听辨出来剧情联动后发言的角色会引用前一个角色提到的细节视角不漂移角色不会突然像上帝视角解释剧情旁白位置动作描写不会占用某个角色的发言秩序稳定不会出现一个角色连说三遍旁人完全沉默7.4 上下文截断影响测试人为把max_context_length调小触发摘要或裁剪逻辑。看角色是否还能记住最近的冲突前提。若发现角色遗忘严重需要增加上下文摘要节点。7.5 稳定性测试同一开场白连续运行 5 次记录每次是什么角色率先发言、最终完成轮数、有没有卡死。群聊系统最大的问题不是“某一个结果不精彩”而是“结果不可控、经常卡住”。如果 5 次中有 2 次因格式解析失败而出现超长等待就需要修改提示词或 stop 词配置。8. 接口 API 与批量任务设计群聊跑通之后最好把它封装成服务或脚本方便后续批量测试不同故事线。8.1 封装成简单接口如果你用 Python 的 FastAPI 做服务端可以暴露一个接口POST /group-chat# app.py 示例实际项目需要补全依赖 from fastapi import FastAPI from pydantic import BaseModel app FastAPI() class GroupChatRequest(BaseModel): opening_stage: str character_names: list[str] max_rounds: int 8 app.post(/group-chat) def run_chat(req: GroupChatRequest): # 假设你已经把角色配置存放在 config 里 session build_session_from_request(req) result run_group_chat(session) return {messages: result}实际启动运行时的命令uvicorn app:app --host 127.0.0.1 --port 78618.2 批量测试请求把 20 组开场白写入一个 JSON 文件批量发起测试import requests test_url http://127.0.0.1:7861/group-chat with open(test_cases.json, r, encodingutf-8) as f: cases json.load(f) for index, case in enumerate(cases): try: resp requests.post(test_url, jsoncase, timeout180) resp.raise_for_status() print(fcase {index}: success) except Exception as err: print(fcase {index}: failed, {err})批量任务必须有失败重试和结果落盘。多轮群聊调用耗时很长不要在内存里只保留最近一次结果。每次流程跑完把会话记录存成 JSON 或 Markdown 文件格式尽量如下{ case_id: rainy_station_001, opening: 雨夜三人躲进废弃车站。, characters: [林澈, 王昭, 苏禾], messages: [], status: finished, error: null }9. 资源占用与性能观察群聊场景对资源的消耗和单用户对话不一样。9.1 推理请求数单聊一轮只发一个请求。三人群聊每轮最多发一个请求甚至可能需要两个请求先选发言人再生成内容。长剧情的总 token 消耗会成倍放大。测试时把响应日志打开能看到每次请求消耗的 prompt tokens 和 completion tokens。9.2 显存与内存使用 OpenCL 或本地 API 服务时需要常驻模型进程。7B 模型量化后通常需要至少 8GB 显存才能保持较高速度CPU 推理下要预留更大内存。但最终占用要以你实际使用的模型版本为基准不同量化等级差别很大。一个安全的做法是启动时用nvidia-smi观察显存基线然后跑一次 6 轮群聊记录显存峰值。nvidia-smi --query-gpuname,memory.total,memory.used --formatcsv长时间运行本地模型后记得清理进程。显存不释放往往是旧进程占着而不是模型本身有多吃显存。9.3 上下文长度增长每开一个群聊会话消息数量会按轮数增加。上下文一长推理时间变长如果超过模型窗口接口直接报错。比较好的做法是给长会话加“摘要节点”当历史超过 N 条消息时把前 N 条消息压缩为一段“事件回顾”由旁白以narrator身份写回会话记录。9.4 避免端口冲突本地部署多个模型服务时端口冲突比较常见。建议为不同服务固定不同端口在每次启动前检查端口占用netstat -ano | findstr 786110. 群聊效果的常见问题与排查方法问题现象可能原因排查方式解决方案模型输出混杂多个角色提示词未限定“一段只输出一个角色”看完整返回原始字符串添加 stop 词改为 JSON 输出增加格式惩罚样例某个角色全程沉默发言调度没有覆盖它的优先级或系统提示词压制了它的主动性打印每轮发言角色名单调整调度权重为静默角色添加“主动出击”的角色提示角色说话语气越来越像同一人上下文拼接未区分角色标签检查历史消息是否丢了发送者标记每条历史写为“角色名说内容”剧情跑到一半角色忘掉开头上下文过长被截断看最近请求实际传入的 token 数引入摘要模块保留最近的重要细节请求频繁超时并发请求堆积或单轮内容过长看服务端日志降并发缩短单条输出上限多次运行结果差异巨大温度过高或者无 seed 设置记录每个请求的采样参数设置 temperature 在 0.6~0.9稳定性测试时可固定 seed旁白和动作全被当成角色台词解析器无法区分旁白块检查旁白是否带独立 token 标记流程中单独处理旁白不把它写入角色发言段角色回复自己的话或是复读机消息历史混乱或上下文重复注入检查历史拼接逻辑对每次请求只追加上次的新消息不重复发送整段积累消息11. 从“能聊”到“能接戏”的最佳实践做一个真正能“互相接戏”的群聊不只是调度代码这么简单。我建议在角色配置上下更大功夫。11.1 角色卡要写“关系”而不是只写“性格”好的群聊角色设定要包含与场景中其他人的互动目标。例如A 角色设定中写“A 怀疑 B 有嫌疑但不想当面撕破会更委婉地试探”这比单纯写“A 性格谨慎”有效得多。角色间关系直接影响接戏的张力。11.2 开场白最好设计成“戏剧张力钩子”不要给平淡场景。测试接戏能力时应该给一句能产生回应或分歧的动态事件三人刚进入安全屋门外传来有人用 A 的名字叫门的声音而三人都知道 A 已经在三年前签署了失踪名单。11.3 对局内动作保持轻量群聊运行时模型不擅长处理一串复杂动作。把动作拆分到最小数一次只让角色做一个动作。比如“走到窗前”就够了不要同时“走到窗前、打开手机、瞥了一眼、叹口气、说……”11.4 保留完整日志备查每轮跑完保存一个日志文件记录轮次、当前发言人、模型返回的原始文本、解析后的结构化文本。格式解析出问题的时候单靠看最终对话是查不出原因必须回放原始输出。11.5 给输出加二次过滤即使模型输出被解析成功也建议加一个简单的文本过滤层。例如过滤用户设置的字数限制、敏感词表超限内容、空内容。这一步虽然麻烦但能有效避免一整轮脚本因为一条坏回复中断全剧情。11.6 不要误解“AI 自我发挥”群聊自动接戏不等于完全脱离控制。轮数上限、连续性检查、人工中断机制都应该保留。多个角色互相接戏的测试中想让结果达到较高质量仍需要人工在重要节点上做干预和修正AI 生成内容可以作为草稿、创意辅助或灵感来源不建议在未进行人工审核场景中直接公开发布或商用。12. 总结与下一步这个项目的核心价值在于展示了一种很常见的实践需求不是“用户催着 AI 走”而是“给 AI 一个舞台让多个角色自己动起来”。从技术实现角度你只需要先做固定轮值调度把消息记录和角色卡拼装跑通再用 JSON 格式和 stop 词解决输出漂移问题然后逐步加入发言人选择和上下文摘要就可以在本地跑出一个相当稳定的三人群聊。最容易踩的坑有三个第一是角色提示词互相冲突导致所有人像同一个模型在自问自答第二是模型输出混杂多角色解析器兜不住第三是长剧情忘前情需要引入摘要机制而不是单纯拉长上下文窗口。建议收藏备用拿到项目后优先验证一件事让三个角色在 10 轮内围绕同一个具体物件发生冲突再看模型是否保持了每人的说话方式和前情记忆。这一项过了后面的接口扩展和批量测试都顺手许多。如果你想继续深入下一步可以尝试给群聊加“导演”方案用户在每个阶段旁边追加点评让系统根据点评调整下一轮剧情走向。这也将是群聊从“轮流发言”走向“共创叙事”的下一阶段。