Agent 群聊智能发言权判断实现复盘
这一篇我们继续整理 Agent 群聊发言权判断 的升级实现。此前群聊选择谁回复,主要依赖点名、关键词和 LLM 对 Agent 人设的基础理解;升级之后,系统会综合 Agent 性格、关系阶段、最近发言频率和用户情绪来判断谁更适合发言。
这一次的目标不是完全抛弃规则,而是让发言权判断从谁被点名,进一步升级为谁在这个场景里最该接话。
群聊里最容易出现两个问题。一个是某个 Agent 总是抢话,另一个是系统只会根据你们、大家、怎么看这类关键词决定多人回复。
但真实的 AI 电子伴侣群聊里,谁该说话应该取决于更多信息。
比如用户很难过时,应该优先让温柔、稳定、关系更熟的 Agent 回应;用户在纠结选择时,应该优先让理性、清晰、擅长分析的 Agent 回应;用户情绪激烈时,应该让边界感强、能降温的 Agent 回应。某个 Agent 最近已经连续说了很多次,就应该适当降权;如果用户明确点名某个 Agent,仍然优先尊重点名。
所以这一轮实现要表达的是:点名和关键词仍然有效,但不再是唯一依据。
新的发言权判断会综合四类信号:Agent 人设、关系阶段、最近发言频率和用户情绪。Agent 人设来自简介、说明、性格、语气和默认提示词;关系阶段来自用户和某个 Agent 的一对一聊天深度;最近发言频率用来判断某个 Agent 最近是不是说太多;用户情绪则告诉系统,用户当前是需要安慰、建议、降温,还是轻松闲聊。
LangGraph 流程也从原来的:
1classifyIntent -> selectAgents -> generateReplies -> generateCrossReplies -> checkQuality
升级为:
1classifyIntent -> detectEmotion -> selectAgents -> generateReplies -> generateCrossReplies -> checkQuality
也就是说,在真正选择 Agent 前,先增加一个轻量情绪识别节点。
为了判断关系阶段,群聊成员查询需要拿到用户和每个 Agent 的一对一会话统计。
在 apps/api/src/auth/repository.ts 中,AgentGroupChatAgentRecord 新增两个字段:
01export type AgentGroupChatAgentRecord = {02id: string03name: string04headline: string | null05description: string | null06storyBackground: string | null07personalityPrompt: string | null08tonePrompt: string | null09guardrailsPrompt: string | null10defaultPrompt: string | null11imageKey: string | null12displayOrder: number13conversationMessageCount: number14conversationLastMessageAtMs: number | null15}
查询群聊成员时,通过 agent_conversations 表拿到 message count:
1conversationMessageCount: sql<number>`coalesce(${agentConversations.messageCount}, 0)`,2conversationLastMessageAtMs: agentConversations.lastMessageAtMs,
并通过 left join 关联:
1.leftJoin(2agentConversations,3and(4eq(agentConversations.userId, agentGroupChatMembers.userId),5eq(agentConversations.agentId, agentGroupChatMembers.agentId),6),7)
这个改动没有新增数据库字段,也不需要 D1 迁移。关系阶段直接从已有一对一会话统计推导。
在 apps/api/src/routes/chat/group.route.ts 中新增了发言权上下文:
01type AgentSpeakingContext = {02agentId: string03conversationMessageCount: number04recentReplyCount: number05lastSpokeTurnsAgo: number | null06relationshipStage: 'new_connection' | 'warming_up' | 'trusted' | 'close_bond'07relationshipScore: number08freshnessScore: number09}1011type GroupSpeakingContext = {12userEmotion: GroupChatUserEmotion13agentContexts: AgentSpeakingContext[]14}
这些字段描述了一个 Agent 在当前群聊里的发言条件。conversationMessageCount 表示用户和该 Agent 的一对一消息数;recentReplyCount 表示该 Agent 在最近群聊里的发言次数;lastSpokeTurnsAgo 表示距离该 Agent 上次发言过了多少轮;relationshipStage 和 relationshipScore 描述关系阶段和熟悉度;freshnessScore 则表示发言新鲜度,最近说太多会降低。
当前关系阶段先用消息数量做启发式推导:
01function getRelationshipStageFromMessageCount(messageCount: number): AgentSpeakingContext['relationshipStage'] {02if (messageCount >= 80) {03return 'close_bond'04}0506if (messageCount >= 30) {07return 'trusted'08}0910if (messageCount >= 8) {11return 'warming_up'12}1314return 'new_connection'15}
对应关系分数:
01function getRelationshipScore(stage: AgentSpeakingContext['relationshipStage']) {02if (stage === 'close_bond') {03return 0.9504}0506if (stage === 'trusted') {07return 0.7808}0910if (stage === 'warming_up') {11return 0.5212}1314return 0.2515}
这还不是完整形态,但足够作为第一轮信号。后面可以接入更完整的关系阶段系统,而不是只看消息数。
为了避免某个 Agent 连续抢话,系统会读取最近群聊消息中的 Agent 发言:
1const recentAgentMessages = params.recentMessages2.filter((message) => message.senderType === 'agent' && message.agentId)3.slice(-18)
然后对每个 Agent 计算:
1const messagesByAgent = recentAgentMessages.filter((message) => message.agentId === agent.id)2const lastMessage = messagesByAgent.at(-1)3const lastSpokeTurnsAgo = lastMessage ? Math.max(0, maxTurnIndex - lastMessage.turnIndex) : null4const freshnessBase = lastSpokeTurnsAgo === null ? 1 : Math.min(1, lastSpokeTurnsAgo / 6)5const freshnessPenalty = Math.min(0.75, messagesByAgent.length * 0.16)6const freshnessScore = Math.max(0, Number((freshnessBase - freshnessPenalty).toFixed(2)))
这段计算的思路很直接:很久没说话的 Agent,新鲜度更高;最近说过太多次的 Agent,新鲜度降低;如果刚刚说过,后面的 fallback 还会额外降权。
新增了结构化用户情绪:
01const GroupChatUserEmotionSchema = z.object({02primaryEmotion: z.enum([03'neutral',04'happy',05'sad',06'anxious',07'angry',08'lonely',09'stressed',10'confused',11'romantic',12'playful',13'unknown',14]),15intensity: z.number().min(0).max(1),16needsComfort: z.boolean(),17needsAdvice: z.boolean(),18needsDeescalation: z.boolean(),19socialEnergy: z.enum(['low', 'medium', 'high']),20reason: z.string().trim().max(400),21})
情绪识别 prompt 是独立的:
01const groupChatUserEmotionPrompt = ChatPromptTemplate.fromMessages([02[03'system',04[05'你是 AI 电子伴侣群聊的用户情绪识别器。',06'只判断用户本轮消息体现出的主要情绪、强度和陪伴需求,不要生成聊天回复。',07'需要区分用户是需要安慰、建议、降温,还是只是轻松闲聊。',08].join('\n'),09],10])
它只负责判断情绪,不负责选择 Agent,也不生成回复。
LLM 情绪识别失败时,会使用本地启发式:
01function buildFallbackGroupUserEmotion(userText: string): GroupChatUserEmotion {02const sad = /(难过|伤心|委屈|想哭|失落|崩溃|没人懂|孤独|孤单)/.test(text)03const anxious = /(焦虑|紧张|慌|害怕|担心|压力|睡不着|不安)/.test(text)04const angry = /(生气|愤怒|烦死|气死|吵架|不爽|火大)/.test(text)05const romantic = /(喜欢|想你|暧昧|心动|恋爱|约会|亲密|撒娇)/.test(text)06const playful = /(哈哈|笑死|好玩|逗|开玩笑|hh|lol)/i.test(lower)07const happy = /(开心|高兴|快乐|惊喜|太好了|舒服了)/.test(text)08const confused = /(怎么办|不知道|纠结|迷茫|怎么选|不懂|为什么)/.test(text)0910return GroupChatUserEmotionSchema.parse({11primaryEmotion: ...,12needsComfort: sad || anxious || angry || /陪陪|安慰|抱抱|难受/.test(text),13needsAdvice: confused || /(建议|分析|复盘|怎么做|帮我想|选择)/.test(text),14needsDeescalation: angry || /(冷静|别吵|缓一缓|降温)/.test(text),15})16}
这样即使结构化 LLM 调用失败,系统仍然能根据情绪做基本发言权判断。
关键函数是 buildGroupSpeakingContext:
01function buildGroupSpeakingContext(params: {02agents: AgentGroupChatAgentRecord[]03recentMessages: AgentGroupChatMessageRecord[]04userText: string05userEmotion?: GroupChatUserEmotion | null06}): GroupSpeakingContext {07...0809return {10userEmotion: params.userEmotion ?? buildFallbackGroupUserEmotion(params.userText),11agentContexts,12}13}
它会把用户情绪和每个 Agent 的发言权信号汇总成一个对象,后续同时给 LLM 选择器和 fallback 打分使用。
Agent 选择 prompt 从根据人设和最近聊天选择,升级为根据意图、情绪、人设、关系阶段和发言频率选择:
01const groupChatAgentSelectionPrompt = ChatPromptTemplate.fromMessages([02[03'system',04[05'你是 AI 电子伴侣群聊的发言权调度器。',06'请根据意图判断、用户情绪、Agent 人设、关系阶段和最近发言频率,选择最适合回复的 Agent。',07`最多选择 ${groupReplyAgentLimit} 个 Agent。`,08'不要为了热闹而选择过多 Agent。只有用户明确询问大家意见、要求分别回答、需要多视角或高情绪强度需要接力陪伴时,才选择多个。',09'选择原则:',10'- 情绪低落、孤独、焦虑时,优先选择性格稳定、温柔、善于共情且关系更熟的 Agent。',11'- 用户需要分析、计划、建议时,优先选择理性、清晰、擅长复盘的 Agent。',12'- 用户情绪激烈或冲突升级时,优先选择边界感强、克制、能降温的 Agent。',13'- 用户轻松玩笑或高社交能量时,可以选择更活泼、有趣的 Agent。',14'- 最近发言太频繁的 Agent 要适当降权,避免一个 Agent 连续抢话。',15'- 长期关系更深的 Agent 可以优先承接高情绪强度内容,但不能无视人设匹配。',16'输出 selectedAgentIds 时必须使用给定 Agent 的 id。',17].join('\n'),18],19])
同时在 user message 中加入:
1'发言权上下文:',2'{speakingContext}',
格式化后的上下文会包含用户主情绪、情绪强度、是否需要安慰、是否需要建议、是否需要降温和社交能量,也会包含每个 Agent 的关系阶段、一对一消息数、最近发言次数、距离上次发言轮数和新鲜度。
新增节点:
01async function detectGroupEmotionNode(state: typeof GroupChatOrchestrationState.State) {02const userEmotion = await detectGroupUserEmotionWithLangGraph({03providerConfig: state.providerConfig,04groupChat: state.groupChat,05recentMessages: state.recentMessages,06userText: state.userText,07signal: state.signal,08})0910return {11speakingContext: buildGroupSpeakingContext({12agents: state.agents,13recentMessages: state.recentMessages,14userText: state.userText,15userEmotion,16}),17}18}
图结构变成:
01const groupChatOrchestrationGraph = new StateGraph(GroupChatOrchestrationState)02.addNode('classifyIntent', classifyGroupIntentNode)03.addNode('detectEmotion', detectGroupEmotionNode)04.addNode('selectAgents', selectGroupAgentsNode)05.addNode('generateReplies', generateGroupRepliesNode)06.addNode('generateCrossReplies', generateCrossAgentRepliesNode)07.addNode('checkQuality', checkGroupReplyQualityNode)08.addEdge(START, 'classifyIntent')09.addEdge('classifyIntent', 'detectEmotion')10.addEdge('detectEmotion', 'selectAgents')11.addEdge('selectAgents', 'generateReplies')12.addEdge('generateReplies', 'generateCrossReplies')13.addEdge('generateCrossReplies', 'checkQuality')14.addEdge('checkQuality', END)15.compile()
这让选择 Agent 之前,系统已经有了完整的用户情绪和发言权上下文。
旧 fallback 的逻辑很直接:点名谁,就谁回复;有你们、大家等关键词,就多个 Agent 回复;否则第一个 Agent 回复。
新 fallback 保留点名优先,但非点名场景改成打分排序:
01return [...params.agents]02.map((agent) => ({03agent,04score: scoreAgentForFallbackSelection({05agent,06userText: params.userText,07userEmotion: speakingContext.userEmotion,08context: contextByAgentId.get(agent.id),09}),10}))11.sort((a, b) => b.score - a.score || a.agent.displayOrder - b.agent.displayOrder)12.slice(0, limit)13.map((item) => item.agent)
这样即使 LLM 选择失败,本地 fallback 仍然能考虑谁最近说太多、谁和用户更熟、谁的人设更适合当前情绪,以及当前是否需要多人回复。
打分函数是 scoreAgentForFallbackSelection:
1if (params.context) {2score += params.context.relationshipScore * 1.63score += params.context.freshnessScore * 1.84score -= params.context.recentReplyCount * 0.4556if (params.context.lastSpokeTurnsAgo === 0) {7score -= 0.98}9}
然后根据用户情绪匹配 Agent 人设关键词:
01if (params.userEmotion.needsComfort && /(温柔|陪伴|情绪|安慰|稳定|倾听|治愈|共情)/.test(profileText)) {02score += 2.403}0405if (params.userEmotion.needsAdvice && /(理性|分析|建议|计划|复盘|清醒|判断|策略)/.test(profileText)) {06score += 2.107}0809if (params.userEmotion.needsDeescalation && /(克制|边界|冷静|稳定|成熟|安全)/.test(profileText)) {10score += 2.211}
这不是要替代 LLM,而是在 LLM 不可用时提供一个比关键词更稳的底座。
每条 Agent 消息保存时,metadata_json 中会记录本轮编排信息:
01metadataJson: JSON.stringify({02source: 'group_chat_agent',03selectedBy: 'langgraph_v1',04model: providerConfig.model,05wireApi: providerConfig.wireApi,06replyKind: reply.replyKind ?? 'primary',07respondToAgentId: reply.respondToAgentId ?? null,08crossReplyReason: reply.crossReplyReason ?? null,09crossReplyRound: reply.crossReplyRound ?? null,10orchestration: {11intent: orchestration.intent,12selection: orchestration.selection,13speakingContext: orchestration.speakingContext,14crossReplyPlan: orchestration.crossReplyPlan,15quality: orchestration.quality,16},17})
有了这份 metadata,后面就可以排查当时识别到的用户情绪是什么、每个 Agent 的关系阶段和发言频率是什么、为什么选择了这些 Agent、是否触发了 Agent 间补充回应,以及回复质量检查结果如何。
这个能力完全发生在 API 编排层,前端仍然接收原来的:
1agentMessages: AgentGroupChatMessage[]
因为发言权判断只是决定谁回复,不改变消息展示协议。前端无需知道选择逻辑,也不需要同步改 UI。
后续如果要做后台分析页,可以再从 metadata_json 中读取 speakingContext 展示。
当前实现仍然有一些边界。关系阶段暂时从一对一消息数推导,还不是完整关系阶段模型;fallback 人设匹配使用关键词,不是 embedding 或分类器;发言频率只看最近一段群聊消息,不做长期统计;情绪识别是群聊局部轻量版,没有完整复用一对一聊天里的复杂链路;也还没有给用户提供更安静 / 更热闹的群聊偏好设置。
这些边界是有意保留的。这一版重点是把发言权判断从关键词推进到多信号决策,而不是一次性做成复杂调度系统。
后面可以逐步升级。比如把一对一聊天里的关系阶段系统复用到群聊,给 Agent 增加结构化能力标签,例如情绪陪伴、理性分析、活跃破冰;也可以使用 embedding 或分类器匹配用户需求与 Agent 能力,增加用户级群聊偏好:安静、均衡、热闹。再往后,还可以记录长期发言统计,避免某些 Agent 在多轮中长期过度活跃,并在后台分析页展示每轮选择理由和打分结果。
这一轮实现让群聊发言权判断从关键词触发升级成了上下文调度。
系统现在会先理解用户在说什么、情绪如何,再结合每个 Agent 的人设、关系阶段和最近发言频率来决定谁最适合回复。
这对 AI 电子伴侣群聊非常关键。好的群聊不是每个 Agent 都抢着说话,而是合适的人在合适的时候接住用户。