
不知道大家有没有遇到过一种情况单角色 AI 聊天非常投入剧情推进到某个节点后想再加一个角色进来互动结果发现要么新角色完全不参与要么一开口就变成“两个角色抢戏”聊着聊着就把主线带跑偏了。这次参加 AI 创作类的比赛我给自己定了一个比较有意思的目标给 AI 角色扮演加一个“群聊模式”让三个性格完全不同的角色在同一个话题下互相接戏。最终实现的版本不算复杂但整个过程把多角色上下文、发言调度、剧情记忆等问题都过了一遍所以我把它写成一篇偏实战的技术复盘。如果你也在做 AI Agent、角色扮演机器人、多人对话生成类应用这篇文章应该能给你一套可以落地的思路。文章会从“为什么单角色容易但多角色难”开始讲再给出一套基于 FastAPI 大语言模型接口的完整工程案例。代码可以直接复制到本地跑只要你有一个能用的模型 API Key 就行。1. 什么是“AI 酒馆”中的群聊需求1.1 AI 角色扮演的人气玩法“AI 酒馆”这个词在创作者圈子里其实指的不是某个固定产品而是一种玩法用户给 AI 提前设定好人设、背景、说话风格然后让它以这个角色身份陪你聊天。这类玩法在 B 站、小红书、贴吧都有不少创作者在做常见的载体包括网页版聊天机器人、移动端应用、开源角色扮演平台等。支撑这种玩法的核心是一个叫“人物卡”的东西。人物卡本质上是一段结构化的角色描述通常会包含以下信息角色姓名、年龄、职业。性格标签和说话习惯。兴趣爱好、人生经历。当前所处的剧情背景。角色在一段关系中希望达成的目标。有了人物卡之后AI 就不再是“通用问答助手”而是一个有记忆、有动机、有语言风格的虚拟角色。单角色玩法发展到现在已经很成熟了。真正让人头疼的是“把多个角色放进同一个聊天室”让他们像真人群聊一样你一言我一语地接话。1.2 群聊场景和单角色聊天差异巨大单角色对话中上下文很好组织用户说的话、AI 以角色身份说的话按顺序拼成消息列表再结合人物卡一起发给模型就行。群聊则完全不一样。至少多了下面几个问题第一谁先说话三条角色消息不会自己排队如果每次都让所有角色同时回复对话会变得像“话痨刷屏”而且很难形成有来有回的接戏节奏。第二角色之间怎么互相理解每个角色只应该知道“当前群里已经公开说了什么”但不应该直接看到其他角色的完整内心戏。第三如何保证接戏而不是各聊各的真人群里大家会围绕某个人刚抛出的点进行回应。比如有人提议周末露营另一个人接话说天气第三个人吐槽装备。可机器人生成时如果没有导演视角三个角色很容易变成三个完全无关的“个人独白”。第四上下文窗口有限。三个角色各聊一句GPT、Claude 这类模型能处理但长时间聊下去历史消息会越来越长最终越过模型上下文窗口。所以多角色群聊不能简单地写成“for 循环调用三次模型”而是需要一个轻量级的对话调度引擎。2. 整体方案设计2.1 技术选型我的方案没有使用任何需要下载客户端的开源角色扮演平台而是直接用代码写了一套后端服务。理由有三点代码可控性强能看清每一步消息是怎么构造的。方便接入不同的模型 API。后续可以扩展成带记忆、带工具调用、带任务目标的 Agent 系统。整体技术栈如下Python3.10 或以上。Web 框架FastAPI。HTTP 请求库httpx用来请求模型接口。模型接口任何兼容 OpenAI Chat Completions 格式的模型服务。之所以选择兼容 OpenAI 格式的接口是因为国内国外很多模型服务商都提供这种协议只需要改 base_url、model 和 api_key就能切换模型。2.2 系统模块划分系统分成四个模块职责非常明确前端 / 命令行 ↓ FastAPI 服务接收用户消息、管理会话 ↓ ChatEngine群聊调度引擎 ↓ LLMClient统一封装模型接口其中 ChatEngine 是核心它内部又分成几个角色消息中心维护当前会话里的公开消息列表。导演模块负责判断“接下来该谁开口”。发言人模块让被选中的角色根据导演小抄和公开聊天记录生成一句自然的回复。剧情记忆模块当聊天过长时把旧消息压缩成摘要避免上下文膨胀。由于是比赛 Demo我没有引入 Redis、消息队列这类中间件先把所有会话保存在进程内内存中。如果后续要做成线上服务只需要把 MessageStore 替换成数据库即可。3. 环境准备与工程结构3.1 本地环境开发时我使用的是 macOS 自带的终端代码也可以直接运行在 Windows 和 Linux 上。推荐版本Python3.10 及以上 FastAPI0.110 及以上 httpx0.27 及以上 uvicorn0.29 及以上不同版本 API 差异不大重点在于理解实现思路。如果你使用的是较老版本依赖请以实际环境为准。3.2 依赖与目录先创建项目目录mkdir ai_group_chat cd ai_group_chat创建虚拟环境python3 -m venv .venv source .venv/bin/activate创建requirements.txtfastapi uvicorn httpx pydantic python-dotenv安装依赖pip install -r requirements.txt目录结构如下ai_group_chat/ ├── requirements.txt ├── .env.example ├── characters/ │ ├── xiaoyu.json │ ├── achen.json │ └── laolei.json ├── models.py ├── llm_client.py ├── chat_engine.py └── main.py4. 人物卡设计让每个角色有“人设”4.1 人物卡 JSON 示例先说结论人物卡的最小结构不要做得太复杂但关键字段一个都不能少。我建议至少包含id、name、persona、speech_style、goals、secrets 几个部分。下面是我们群聊里的三个角色。第一个角色是小雨班级里的组织者性格偏稳重。characters/xiaoyu.json{ id: xiaoyu, name: 小雨, persona: 28岁互联网公司产品经理性格温和但有主见。在朋友群中经常扮演组织者和粘合剂。喜欢提前定计划关注天气、路线、预算等细节。, speech_style: 说话有条理经常用提问结尾偶尔会提醒大家注意安全和时间。, goals: 希望这次周末活动能顺利成行让每个朋友都玩得开心。, mental: 她觉得阿澈的想法虽然浪漫但容易跑偏老雷说话直接但建议往往有用。 }第二个角色是阿澈文艺青年脑洞很大。characters/achen.json{ id: achen, name: 阿澈, persona: 26岁自由插画师喜欢哲学书、独立音乐和深夜散步。思维跳跃常把一个普通话题延伸成浪漫故事。, speech_style: 说话带比喻偶尔冒出诗一样的句子让人接话时有点意外。, goals: 想在露营时拍一组有氛围感的照片顺便收集一些写作灵感。, mental: 他觉得露营不一定要把所有事情都计划完美留点意外才有趣。 }第三个角色是老雷程序员擅长吐槽。characters/laolei.json{ id: laolei, name: 老雷, persona: 30岁后端开发工程师性格直率有点毒舌。对装备、路线、天气比较敏感习惯从风险角度泼冷水。, speech_style: 说话简短直接经常吐槽但吐槽背后其实是关心。, goals: 确保这次露营不会因为没带够东西而变成灾难。, mental: 他其实愿意参加活动只是不想玩得太累。 }这三个人设的设计不是随手写的而是有讲究的。要让群聊“接得住戏”角色性格需要有明显差异同时角色之间还要有能产生冲突或互补的关系。小雨是从计划角度发言阿澈从感性角度接话老雷负责挑刺话题自然就会不断延展。4.2 加载人物卡代码人物卡是 JSON但为了避免每次手写json.load导致代码七零八落我建议统一封装加载函数。models.pyfrom typing import Optional from pydantic import BaseModel class Character(BaseModel): id: str name: str persona: str speech_style: str goals: str mental: str class ActorMessage(BaseModel): session_id: str role: str speaker: Optional[str] text: str class ChatRequest(BaseModel): text: str rounds: int 1 class SimulateRequest(BaseModel): rounds: int 2这里的ActorMessage.role是 user 或 assistant类似 OpenAI messages 中的角色。speaker记录是哪个人物说的便于前端按头像展示。加载人物卡import json def load_character(path: str) - Character: 从 JSON 文件加载角色卡 with open(path, r, encodingutf-8) as f: data json.load(f) return Character(**data)4.3 为什么 system prompt 里不能只放人物卡很多第一次做这个玩法的人会把人物卡直接拼进 system prompt。这种写法在最简单场景没问题但在群聊中不够。原因在于同一段文字里塞入了太多目标。你既希望角色保持人设又希望它看群聊上下文还想让它听导演调度。所以我的方案是分三层全局系统提示词告诉模型这是群聊输出要口语化不要输出 JSON 多余解释。人物卡拼成的角色指令只有某个角色发言时会拼进去。导演小抄每次调度前先生成一句对当前局面的判断再交给发言人角色。这样能有效避免角色“忘记自己是谁”的问题。5. 模型客户端与兼容封装5.1 为什么要自己封装目前很多模型服务都支持 OpenAI 的 Chat Completions 接口。直接装openaiSDK 也可以但我更习惯用 httpx 手动封装一层。好处是依赖少出错时能看到完整 HTTP 信息切换模型时也不用跟着 SDK 版本跑。llm_client.pyimport json import os import httpx class LLMClient: 统一的模型请求客户端 使用环境变量配置适合本地开发和快速演示。 def __init__(self): self.api_key os.getenv(LLM_API_KEY, ) self.base_url os.getenv(LLM_BASE_URL, https://api.openai.com/v1).rstrip(/) self.model os.getenv(LLM_MODEL, gpt-4o-mini) if not self.api_key: raise RuntimeError(请先设置 LLM_API_KEY 环境变量) async def chat( self, messages, temperature: float 1.0, max_tokens: int 1024, ): url f{self.base_url}/chat/completions headers { Authorization: fBearer {self.api_key}, Content-Type: application/json, } payload { model: self.model, messages: messages, temperature: temperature, max_tokens: max_tokens, } async with httpx.AsyncClient(timeout60) as client: resp await client.post(url, jsonpayload, headersheaders) resp.raise_for_status() data resp.json() content data[choices][0][message][content] return content staticmethod def extract_json(text: str): 从模型输出中稳健提取 JSON 对象 if not text: return {} text text.strip() if text.startswith(): text text.strip() if text.startswith(json): text text[4:] start text.find({) end text.rfind(}) if start -1 or end -1 or end start: return {} try: return json.loads(text[start:end 1]) except json.JSONDecodeError: return {}extract_json这段代码是必要的。因为不少模型返回的 JSON 可能会被json包裹偶尔也会多解释一句话。手动截取最外层大括号比直接 json.loads 更稳。6. ChatEngine多角色群聊调度引擎这是最关键的模块。建议第一次实现时不要把并行发消息、多人同一轮发声这类复杂逻辑加进来先做一个“每轮只让一个角色说话”的串行版本。串行版本的优点是上下文清晰不会出现两个角色同时回复导致逻辑混乱API 调用数量可控问题更容易排查。6.1 会话结构import uuid from typing import Dict, List from models import Character, ActorMessage class Session: def __init__(self, session_id: str, characters: Dict[str, Character]): self.session_id session_id self.characters characters self.messages: List[ActorMessage] [] self.director_memory: List[str] [] def add_message(self, role: str, speaker: str, text: str): msg ActorMessage( session_idself.session_id, rolerole, speakerspeaker, texttext, ) self.messages.append(msg) return msgSession 保存在内存字典中key 是 session_idvalue 是 Session 实例。6.2 构建消息列表调用模型前要把内部消息转换成 OpenAI messages 格式。系统提示词只保留一次避免每次都重复让模型理解“这是群聊”。def _build_messages(self, system_prompt: str, recent_text: List[Dict]) - List[dict]: messages [{role: system, content: system_prompt}] for item in recent_text[-12:]: role item[role] speaker item.get(speaker) text item[text] if role user: messages.append({role: user, content: f玩家说{text}}) else: messages.append({role: user, content: f{speaker}说{text}}) return messages这里有个细节为了降低模型对“assistant 身份”的混淆我把所有公开发言都包装成 user 消息只有最终生成的待加入消息才作为 assistant。这样模型不会误以为“接下来说话的人就是刚才那批消息的作者”。虽然不够语义化但实测比混合 assistant/user 更容易保持角色独立。6.3 导演模块决定谁开口每次群聊继续前先调用一次模型让“导演”从角色列表中选择下一个发言人。导演不需要太长的上下文只需要给它最近 6~8 条消息以及每个角色的一句话简介。DIRECTOR_PROMPT 你是一个群聊导演。 你管理的群里角色有 {character_list} 最近对话如下 {recent_context} 请你判断为了让群聊继续自然地进行下去下一个最应该开口的角色是谁 输出 JSON{next_speaker: 角色id, reason: 为什么是他/她, director_note: 给他/她的一个表演提示} 如果当前不需要任何人回应输出{next_speaker: none, reason: 对话可以暂停} 不要输出其他内容。导演返回的director_note就是“小抄”。它不会直接给玩家看只会被拼进下一个角色的 system prompt 中。这样做能解决“谁先说话问题”同时让后续角色真正围绕上一个发言点来接戏。6.4 发言人模块生成一句台词选定了发言人之后调用第二个模型接口。第二个模型的 system prompt 由人物卡强化而来def _build_speaker_system_prompt(self, character: Character, director_note: str) - str: return f 你在一个朋友群聊中。 你的身份信息如下 姓名{character.name} 性格背景{character.persona} 说话风格{character.speech_style} 你的内心目标{character.goals} 你看待其他人的态度{character.mental} 你们正在讨论周末是否去露营。 导演给你的小抄{director_note} 请以 {character.name} 的身份说一句符合人设的口语化台词。 要求 1. 不要超过80字。 2. 不要用括号描述动作。 3. 不要同时代替其他角色发言。 4. 如果暂时没有想说的可以输出{{text: }} 5. 其余时候输出{{text: 你的一句话}} 只输出 JSON不要解释。 很多人会问为什么还要允许空文本因为在真实群里不是每个角色每一轮都必须说话。偶尔让某个角色沉默反而会让另外两个角色之间的互动更有张力。6.5 主流程一次群聊更新下面直接给出整个 ChatEngine 的核心实现。chat_engine.pyimport uuid from typing import Dict, List, Optional from llm_client import LLMClient from models import Character, ActorMessage, load_character class ChatEngine: def __init__(self, llm_client: LLMClient, character_paths: Dict[str, str]): self.llm llm_client self.sessions: Dict[str, Dict] {} self.characters: Dict[str, Character] {} for cid, path in character_paths.items(): self.characters[cid] load_character(path) def create_session(self) - str: session_id uuid.uuid4().hex[:12] self.sessions[session_id] { messages: [], } return session_id def _recent_open_messages(self, session_id: str, limit: int 12) - List[dict]: msgs self.sessions[session_id][messages] return msgs[-limit:] async def user_says(self, session_id: str, text: str, rounds: int 1) - List[dict]: 玩家发言后由导演决定后续几轮由谁发言。 rounds 表示最大调度轮数一般取 1~2 比较自然。 self.sessions[session_id][messages].append({ role: user, speaker: 玩家, text: text, }) produced [] for _ in range(rounds): next_speaker await self._pick_speaker(session_id) if not next_speaker: break reply await self._generate_speaker_message(session_id, next_speaker) if reply: produced.append(reply) return produced async def auto_simulate(self, session_id: str, rounds: int 2) - List[dict]: 纯 AI 群聊模式用于测试三个角色是否能自己接上戏 produced [] for _ in range(rounds): next_speaker await self._pick_speaker(session_id) if not next_speaker: break reply await self._generate_speaker_message(session_id, next_speaker) if reply: produced.append(reply) return produced async def _pick_speaker(self, session_id: str) - Optional[str]: 导演选择下一发言人 recent self._recent_open_messages(session_id) recent_text \n.join( f{m[speaker]}: {m[text]} for m in recent ) character_list \n.join( f{c.id}({c.name}): {c.persona[:40]} for c in self.characters.values() ) prompt f你是一个群聊导演。 你管理的群里角色有 {character_list} 最近对话如下 {recent_text} 请判断下一个最应该开口的角色是谁 输出 JSON{{next_speaker: 角色id, reason: 原因, director_note: 给角色的表演提示}} 如果当前无人需要回应输出{{next_speaker: none}} content await self.llm.chat( messages[ {role: system, content: DIRECTOR_BASE_PROMPT}, {role: user, content: prompt}, ], temperature0.7, max_tokens300, ) data self.llm.extract_json(content) speaker_id data.get(next_speaker, ) if speaker_id in (none, ): return None self.sessions[session_id].setdefault(director_notes, []).append( data.get(director_note, ) ) if speaker_id in self.characters: return speaker_id return None async def _generate_speaker_message(self, session_id: str, character_id: str) - Optional[dict]: 让指定角色生成一句回复 character self.characters[character_id] notes self.sessions[session_id].setdefault(director_notes, []) director_note notes[-1] if notes else recent self._recent_open_messages(session_id) recent_text \n.join( f{m[speaker]}: {m[text]} for m in recent ) system_prompt f 你在一个朋友群聊中。 你的身份信息如下 姓名{character.name} 性格背景{character.persona} 说话风格{character.speech_style} 你的内心目标{character.goals} 你看待其他人的态度{character.mental} 导演给你的小抄{director_note} 最近聊天记录 {recent_text} 请以 {character.name} 的身份说一句符合人设的口语化台词。 要求 1. 不要超过80字。 2. 不要用括号描述动作。 3. 不要同时代替其他角色发言。 4. 如果暂时没有想说的可以输出{{text: }} 5. 其余时候输出{{text: 你的一句话}} 只输出 JSON不要解释。 content await self.llm.chat( messages[ {role: system, content: system_prompt}, {role: user, content: 请开始发言。}, ], temperature0.95, max_tokens300, ) data self.llm.extract_json(content) text data.get(text, ).strip() if not text: return None msg { role: assistant, speaker: character.name, text: text, } self.sessions[session_id][messages].append(msg) return msg上述代码的关键点在于每次_pick_speaker只选一个角色不会出现全员乱说。被选中的角色看到的不是自己“必须说什么”而是拥有一个说话的机会。导演 note 会传给角色但对模型来说只是“内部提示”最终输出只是台词。6.6 为什么暂不采用“全局并行回调”我也尝试过另外一种方案把三条人物卡同时发给模型让模型一次输出三个人的发言。优点是调用次数少缺点是上下文非常容易被当成“脚本生成器”生成结果读起来像小说而不是群聊。所以多角色群聊在真实感要求高的场景下更要偏向“顺序调度”而不是“并行合成”。7. FastAPI 接口与运行测试7.1 构建 FastAPI 服务main.pyimport os from dotenv import load_dotenv from fastapi import FastAPI, HTTPException from chat_engine import ChatEngine from llm_client import LLMClient from models import ChatRequest, SimulateRequest load_dotenv() app FastAPI(titleAI Group Chat Engine) llm_client LLMClient() chat_engine ChatEngine( llm_clientllm_client, character_paths{ xiaoyu: characters/xiaoyu.json, achen: characters/achen.json, laolei: characters/laolei.json, }, ) app.get(/health) async def health(): return {status: ok} app.post(/chat/session) async def create_session(): session_id chat_engine.create_session() return {session_id: session_id} app.post(/chat/{session_id}/message) async def send_message(session_id: str, req: ChatRequest): if session_id not in chat_engine.sessions: raise HTTPException(status_code404, detailsession not found) messages await chat_engine.user_says(session_id, req.text, roundsreq.rounds) return {session_id: session_id, new_messages: messages} app.post(/chat/{session_id}/simulate) async def simulate(session_id: str, req: SimulateRequest): if session_id not in chat_engine.sessions: raise HTTPException(status_code404, detailsession not found) messages await chat_engine.auto_simulate(session_id, roundsreq.rounds) return {session_id: session_id, new_messages: messages} app.get(/chat/{session_id}/messages) async def get_messages(session_id: str): if session_id not in chat_engine.sessions: raise HTTPException(status_code404, detailsession not found) return chat_engine.sessions[session_id][messages]7.2 配置文件创建.env.exampleLLM_API_KEYsk-your-key LLM_BASE_URLhttps://api.openai.com/v1 LLM_MODELgpt-4o-mini如果你使用的是国内可直接访问、且兼容 OpenAI 接口的服务只需要把LLM_BASE_URL和LLM_MODEL改成你自己的对应地址与模型名即可。请务必使用合法账号和允许访问的服务地址。7.3 启动与验证启动服务uvicorn main:app --reload --port 8000先创建会话curl -X POST http://127.0.0.1:8000/chat/session返回{session_id: your_session_id}然后发送第一条消息让两个模型轮次接两次话curl -X POST http://127.0.0.1:8000/chat/your_session_id/message \ -H Content-Type: application/json \ -d {text: 下周末三人一起去山顶露营怎么样, rounds: 2}我在一次本地运行中得到的结果大概长这样模型输出有随机性仅供参考{ new_messages: [ { role: assistant, speaker: 小雨, text: 好啊我先查一下天气周六白天应该不错周六晚上风会大一点。 }, { role: assistant, speaker: 老雷, text: 山顶风大别带那种一套就碎的帐篷。晚上气温跑不低多带件外套比什么都强。 } ] }可以看到第二轮老雷的发言回应了小雨的“风大”这个信息点而不是开启一个全新的、无关的话题。这就是导演调度带来的好处。接着可以用/simulate让 AI 角色自己继续演curl -X POST http://127.0.0.1:8000/chat/your_session_id/simulate \ -H Content-Type: application/json \ -d {rounds: 3}这时候阿澈大概率会被导演选中因为他已经“沉默”了两轮。角色是否在场、是否被冷落可以由导演模块自己判断。8. 实际运行效果与调试方法8.1 怎么观察“接戏”效果很多人跑通接口后发现角色确实会说话但没有“接住别人的戏”。这通常不是模型能力问题而是可见上下文信息不足。我的调试方法是把每一次导演调度的director_note和最终台词一起打进日志里。这样能清楚地看到导演希望角色回应什么角色实际回应了什么。例如导演 note 如果是小雨刚提到山顶风大老雷应该顺着装备话题补充一句。老雷生成的台词大概率会提到帐篷、风、温度之类的内容。但如果导演 note 是空的老雷可能就会回复一句“可以啊去呗”完全没有信息量。因此多角色群聊的核心技巧之一就是把“剧情推动”这件事从发言角色身上拆出去交给一个独立的导演模块去分析。很多时候角色不是不会接戏而是没有被明确告知“现在这个戏眼在哪”。8.2 常见问题与排查清单问题现象常见原因解决思路角色一直不开口导演 always 返回 none检查最近对话是否太短或把 director 的 temperature 调高一点两个角色各聊各的导演 note 没有触发对上一句的回应在导演 prompt 里增加“必须优先围绕上一句话回应”每次都是同一个角色发言人设导致某个角色总是被选中给较安静角色增加 attractive 的信息点或者在导演 prompt 中加入“尽量让沉默角色有机会开口”角色输出带括号动作system prompt 不够强硬把“不要用括号描述动作”放在清单第一条并且要求只输出 JSONJSON 解析失败模型返回了额外解释使用 extract_json 方式截取大括号上下文越来越长没有摘要压缩增加 message 裁剪聊到 50 条后保留最近 20 条如果希望把调试日志做得更精细可以在 chat_engine.py 中加一段打印print(f[director] {director_note})这对理解模型行为非常有帮助。9. 工程优化从 Demo 到可用的群聊系统接口能跑通只是第一步。如果想去掉演示标签把它做成一个大家愿意持续使用的角色群聊我认为还需要关注下面几个问题。9.1 记忆压缩当前代码只保留了最近 12 条消息更早的消息会自然丢失。实际群聊中角色偶尔要回忆起“我们刚才是怎么决定去露营的”。最轻量做法是维护一个“群聊摘要”。每累计 30 条消息就调用一次模型把旧消息压缩成 3~5 行剧情摘要替换掉原有旧消息。摘要 prompt 可以这样写请把以下群聊内容压缩成 5 行以内的剧情纪要。 保留已经确定的关键决定、角色之间的关系变化、尚未解决的冲突。 忽略日常寒暄、重复观点。然后在新一轮发言人构建上下文时把摘要作为 system 里的“前情提要”放进去再接最近 10 条完整消息。9.2 角色长期目标记忆人物卡里的 goals 字段是固定的如果一个角色连续 3 轮说“想去拍照”但每次都没人接话系统层面最好能积累一条短期记忆“阿澈两次提出想拍星空但大家都没认真回应。”短期记忆和人物卡分开存每轮调度时动态更新。这样剧情就不会“每轮从零开始”。9.3 控制 API 调用成本串行调度模型调用次数相对比较多玩家发送一条消息如果要三个角色各接一句就是 1 次导演 3 次发言生成共 4 次模型调用。调试阶段建议rounds 设置为 1只让最相关的一个人先回。确认稳定后再考虑 rounds2 甚至 rounds3。必要时在角色发言前加一个“是否真的想说话”的判断不是每轮都强制生成。9.4 前端界面的接入思路当前服务返回结构已经可以方便前端使用。只需要在页面上展示两次请求之间的新消息即可。由于我们没有引入 WebSocket最简单的前端轮询接口就是前面写的GET /chat/{session_id}/messages。前端每隔 2 秒拉一次新消息然后追加到聊天列表中。如果做的是游戏化页面建议客户端不要直接调用模型接口而是统一走 FastAPI 服务。这样可以在服务端加审核过滤、角色内容安全策略、限流等能力而不会把模型 API Key 暴露给浏览器。10. 安全与合规建议角色扮演类应用很容易在开放测试时出现内容失控。我建议即使是纯 Demo也把下面几条基础规则写进系统提示词里。不输出色情、暴力、歧视内容。不生成真实人物的负面传闻。遇到违法话题时角色应拒绝并引导回安全话题。这里考验的不是“审核关键词”而是角色在维持人设的情况下如何拒绝。一种常见写法是在导演 prompt 中加入当聊天内容涉及违法违规、色情低俗、人身攻击等内容时导演必须判定为 none结束当前轮次。与其只依赖底层模型的内置安全策略不如在 prompt 层再做一次独立的场景闸门。尤其是如果以后接入开源本地模型这一点会更明显。另外不要把 API Key 写在仓库里。建议通过.env文件管理环境变量并把.env加入.gitignore。11. 后续可以怎么扩展这次“AI 酒馆 群聊”的 Demo 只是把多角色调度这条主线跑通了。它带来的启发其实可以延伸到很多场景比如多人复盘机器人老板提出问题后让产品、技术、测试三个角色依次分析。游戏 NPC 小队玩家在游戏中触发对话时由多个 NPC 根据人际目标互相补充。剧本杀 DM由导演 Agent 动态安排真假线索控制每个玩家获得的信息差。内容创作辅助让“文案脑暴”“运营吐槽”“用户代表”三个角色一起讨论一个新方案。如果你也在做类似的多 Agent 应用我建议先从本文这套“导演调度 发言人生成”的串行架构开始跑。等你能稳定控制两个角色的接戏节奏再去考虑并行调用、外部记忆、工具使用这些更进阶的能力。下一步我会把群聊从“单会话内存”改成数据库存储并为每个角色加入 Long-Term Memory 的向量检索模块让角色隔几天还记得群里聊过什么。这个版本虽然“戏味”更重但代码却越来越像一套正经的后端 Agent 系统了。如果顺利后面会把数据库版本和检索方案完整分享出来。