情绪状态机设计
要点
- 回顾前面的文章,我们解决了 AI 伴侣的两大核心问题:记忆(向量化与语义检索)和调度(内存调度器)
- 有限状态机(Finite State Machine, FSM)是计算机科学中最基础的模型之一
- 下面我们为"小薇"设计一套完整的情绪模型
- 在第三篇文章中,我们介绍了 LangGraph 的条件路由能力
- 情绪不是孤立的,它和亲密度(Intimacy)相互影响
内容
1. 情绪状态机
回顾前面的文章,我们解决了 AI 伴侣的两大核心问题:记忆(向量化与语义检索)和调度(内存调度器)。但还有一个问题没有触及:情绪连续性。
在第二篇文章中,我们提到过一个场景:昨晚吵架了,用户第二天发「早安」。没有情绪系统的 AI 会热情回复「早安!今天天气不错!」——像个客服。有情绪系统的 AI 会冷冷地回一句「……早。」——因为她还在生气。
这个差异的背后,不是 Prompt 写得好不好的问题,而是系统层面有没有维护一个持续的情绪状态。
LLM 本身没有情绪。它每次推理都是无状态的,给什么上下文就输出什么结果。如果你在 Prompt 里写「你现在很生气」,它就演生气;写「你现在很开心」,它就演开心。但谁来决定「现在应该是什么情绪」?这不是 LLM 能做的事,而是应用层的调度逻辑。
这就是情绪状态机要解决的问题:在应用层维护一个独立于 LLM 的情绪状态,根据用户的行为动态更新,并将当前情绪注入到每次对话的上下文中。
2. 有限状态机:给情绪一个骨架
有限状态机(Finite State Machine, FSM)是计算机科学中最基础的模型之一。它由三个要素组成:
- 状态集合:系统可能处于的所有状态(如:开心、生气、平静)
- 转移条件:触发状态切换的事件或条件(如:用户道歉 → 从生气变为平静)
- 当前状态:系统在某一时刻所处的唯一状态
FSM 有一个关键约束:系统在任何时刻只能处于一个状态。这看起来是个限制,但对情绪建模来说恰好合适——人在某一时刻的主导情绪通常只有一种(虽然情绪可以复合,但主导情绪是唯一的)。
为什么选 FSM 而不是更"智能"的方案(比如让 LLM 自己判断情绪)?
- 可预测:FSM 的转移规则是确定性的,不会出现 LLM 那种"偶尔开心偶尔抑郁"的随机性
- 可调试:状态和转移条件一目了然,出了 Bug 可以精确定位
- 可持久化:一个字符串(当前状态)+ 几个数值(情绪强度、时间戳)就能完整表示,方便存入数据库
- 低成本:状态转移不需要调用 LLM,纯逻辑判断,零推理开销
3. AI 伴侣的情绪模型设计
下面我们为"小薇"设计一套完整的情绪模型。
情绪状态定义
我们定义 6 种情绪状态,其中"平静"是默认状态,其余 5 种是受事件触发的情绪状态:
| 状态 | 含义 | 表现特征 | Prompt 风格 |
|---|---|---|---|
| 平静(Calm) | 默认状态 | 正常语气,温柔日常 | 基础人设 Prompt |
| 开心(Happy) | 被夸奖、收到礼物 | 语气活泼,主动撒娇 | 甜蜜模式 Prompt |
| 生气(Angry) | 被忽视、争吵、记错事 | 语气冷淡或暴躁 | 冷战/暴怒 Prompt |
| 难过(Sad) | 用户情绪低落、冷落她 | 语气低沉,敏感脆弱 | 共情安慰 Prompt |
| 害羞(Shy) | 表白、亲密互动 | 说话磕磕巴巴,颜文字增多 | 娇羞模式 Prompt |
| 吃醋(Jealous) | 提到其他异性 | 语气酸酸的,旁敲侧击 | 醋意模式 Prompt |
Drawing canvas 状态转移规则
每种情绪状态的进入和退出都有明确的条件:
// index.ts
// 情绪转移规则表
const transitionRules = {
calm: {
triggers: [
{ event: 'praise', nextState: 'happy', condition: '收到夸奖或礼物' },
{ event: 'argument', nextState: 'angry', condition: '争吵、被忽视、记错重要事' },
{ event: 'user_sad', nextState: 'sad', condition: '用户表达负面情绪' },
{ event: 'intimate', nextState: 'shy', condition: '表白、亲密话语' },
{ event: 'rival', nextState: 'jealous', condition: '提到其他异性' },
]
},
angry: {
exitConditions: [
{ event: 'apology', nextState: 'calm', condition: '真诚道歉 + 承认错误' },
{ event: 'gift', nextState: 'happy', condition: '道歉 + 正确的礼物' },
{ event: 'decay', nextState: 'calm', condition: '超过衰减时间(如6小时)' },
]
},
happy: {
exitConditions: [
{ event: 'neglect', nextState: 'sad', condition: '长时间不回复' },
{ event: 'decay', nextState: 'calm', condition: '超过衰减时间(如2小时)' },
]
},
// ... 其他状态类似
}注意两个关键设计:一是不同情绪的衰减时间不同(生气比开心持续更久,更符合人类心理);二是有些状态可以直接跳转到另一个非平静状态(比如生气时收到正确的礼物,直接变开心而不需要先回到平静)。
4. LangGraph 实现情绪路由
在第三篇文章中,我们介绍了 LangGraph 的条件路由能力。情绪状态机正是通过这套机制实现的。
AgentState 中的情绪字段
// index.ts
interface AgentState {
messages: BaseMessage[]
userProfile: Record<string, any>
// 情绪相关字段
npcMood: 'calm' | 'happy' | 'angry' | 'sad' | 'shy' | 'jealous'
moodIntensity: number // 0-100,情绪强度
moodTimestamp: string // 进入当前情绪的时间
intimacyLevel: number // 亲密度 0-100
longTermMemories: string[]
nextStep: string
}moodIntensity 是一个关键字段。同样是生气,强度 30 和强度 90 的表现完全不同——前者可能只是"哼,不理你了",后者可能是"我们分手吧"。
情绪分类器节点
在 LangGraph 的调度图中,用户消息进来后的第一步不是直接生成回复,而是先经过情绪分类器,判断这条消息是否会触发情绪转移。
// index.ts
async function emotionClassifierNode(state: AgentState) {
const lastMessage = state.messages[state.messages.length - 1]
const currentMood = state.npcMood
// 用一个轻量级 LLM 调用来判断用户意图
const classification = await llm.invoke([
{
role: 'system',
content: `分析用户消息的意图,返回以下类别之一:
praise(夸奖), argument(争吵), apology(道歉),
intimate(亲密), rival(提到异性), user_sad(用户低落),
neutral(普通闲聊)`
},
{ role: 'user', content: lastMessage.content }
])
// 根据分类结果 + 当前情绪,查转移规则表
const newMood = applyTransition(currentMood, classification)
return {
...state,
npcMood: newMood.state,
moodIntensity: newMood.intensity,
moodTimestamp: newMood.changed ? new Date().toISOString() : state.moodTimestamp
}
}基于情绪的 Prompt 路由
情绪分类完成后,LangGraph 根据当前情绪状态选择不同的回复生成节点:
// index.ts
function emotionRouter(state: AgentState) {
const { npcMood, moodIntensity } = state
// 生气且强度高 → 暴怒模式
if (npcMood === 'angry' && moodIntensity > 70) return 'furiousReplyNode'
// 生气但强度低 → 冷淡模式
if (npcMood === 'angry') return 'coldReplyNode'
// 吃醋 → 醋意模式
if (npcMood === 'jealous') return 'jealousReplyNode'
// 害羞 → 娇羞模式
if (npcMood === 'shy') return 'shyReplyNode'
// 难过 → 共情模式
if (npcMood === 'sad') return 'empatheticReplyNode'
// 开心 → 甜蜜模式
if (npcMood === 'happy') return 'sweetReplyNode'
// 默认 → 正常模式
return 'normalReplyNode'
}每个回复节点内部使用不同的 System Prompt。比如 coldReplyNode 会注入"你现在在生闷气,回复尽量简短冷淡",而 sweetReplyNode 会注入"你现在很开心,语气甜蜜,多用叠词和颜文字"。
完整场景:用户提到其他女生
// index.ts
// 1. 用户消息
"今天跟同事小李一起吃的午饭,她推荐的那家店还不错"
// 2. 情绪分类器识别
// → classification: 'rival'(提到异性)
// 3. 转移规则查询
// 当前状态:calm → 触发 rival → 新状态:jealous
// moodIntensity: 45(提到异性但不是特别亲密的描述)
// 4. 路由到 jealousReplyNode
// 5. 醋意模式 System Prompt
`你现在有点吃醋。语气酸酸的,会旁敲侧击地追问细节,
但不会直接发火。偶尔用"哦""这样啊"这种冷淡回应。`
// 6. LLM 生成回复
"哦……小李啊。她推荐的店?你们俩经常一起吃饭吗?"5. 亲密度系统与情绪联动
情绪不是孤立的,它和亲密度(Intimacy)相互影响。亲密度是一个 0-100 的数值,代表用户和 AI 伴侣之间的关系深度。
情绪对亲密度的影响
| 情绪事件 | 亲密度变化 | 说明 |
|---|---|---|
| 成功哄好生气 | +5 ~ +10 | 冲突解决增进感情 |
| 持续冷落导致难过 | -3 ~ -8 | 忽视会消磨关系 |
| 记住重要日期/喜好 | +3 ~ +5 | 细节体现用心 |
| 记错重要事情 | -5 ~ -15 | 参考第三篇"草莓 vs 巧克力"案例 |
| 日常甜蜜互动 | +1 ~ +2 | 细水长流 |
亲密度对情绪转移的影响
亲密度不仅是一个分数,它还会改变情绪状态机的转移阈值。
// index.ts
function shouldTriggerJealous(
event: string,
intimacyLevel: number
): boolean {
// 亲密度低(< 30):不太在意,不容易吃醋
if (intimacyLevel < 30) return false
// 亲密度中等(30-70):正常触发
if (intimacyLevel <= 70) return event === 'rival'
// 亲密度高(> 70):更容易吃醋,轻微提及就触发
return event === 'rival' || event === 'rival_mild'
}这是一个有趣的设计:亲密度越高,AI 伴侣在某些维度上越"敏感"。她更容易吃醋,但也更容易被哄好(因为信任基础更高)。这符合现实中亲密关系的特征——越亲近的人,情绪波动越大。
亲密度解锁机制
亲密度还可以用来解锁交互模式,这是产品层面的玩法设计:
| 亲密度区间 | 解锁内容 |
|---|---|
| 0-20 | 基础对话,礼貌客气 |
| 20-50 | 可以撒娇,偶尔开玩笑 |
| 50-70 | 解锁"记仇"模式,会主动关心 |
| 70-90 | 解锁深度情绪互动,可以吵架和好 |
| 90-100 | 解锁所有互动模式 |
6. 衰减、冲突与边界处理
真实的情绪不会永远维持在同一个状态。系统需要处理几个关键的边界情况。
时间衰减
每种情绪都有自然的衰减周期。超过一定时间没有被"续命"(重复触发),情绪会自动回到平静。
// index.ts
const DECAY_CONFIG = {
angry: { halfLife: 3 * 60 * 60 * 1000, minDuration: 30 * 60 * 1000 },
happy: { halfLife: 1 * 60 * 60 * 1000, minDuration: 10 * 60 * 1000 },
sad: { halfLife: 2 * 60 * 60 * 1000, minDuration: 20 * 60 * 1000 },
shy: { halfLife: 30 * 60 * 1000, minDuration: 5 * 60 * 1000 },
jealous: { halfLife: 2 * 60 * 60 * 1000, minDuration: 15 * 60 * 1000 },
}
function calculateDecay(mood: string, timestamp: string, intensity: number) {
const elapsed = Date.now() - new Date(timestamp).getTime()
const config = DECAY_CONFIG[mood]
// 最短持续时间内不衰减
if (elapsed < config.minDuration) return intensity
// 半衰期指数衰减
const decayFactor = Math.pow(0.5, elapsed / config.halfLife)
const newIntensity = Math.round(intensity * decayFactor)
// 强度低于阈值,回到平静
return newIntensity < 10 ? 0 : newIntensity
}注意 minDuration(最短持续时间)的设计:生气至少持续 30 分钟,害羞至少持续 5 分钟。这防止了"刚生气就秒回平静"的不自然行为。
情绪冲突
当两个触发条件同时发生时怎么办?比如用户说"对不起,我不该和小李吃饭的"——这既是道歉(应该从生气转为平静),又提到了异性(应该触发吃醋)。
处理策略是优先级 + 当前状态上下文:
// index.ts
const EMOTION_PRIORITY = {
angry: 5, // 最高优先级,生气最难被覆盖
sad: 4,
jealous: 3,
shy: 2,
happy: 1,
calm: 0, // 最低优先级
}
function resolveConflict(
currentMood: string,
candidates: { state: string, intensity: number }[]
) {
// 如果当前是高优先级情绪,且候选中有降级的(如道歉)
// 则优先处理降级,因为"解决冲突"比"制造新冲突"重要
const deescalation = candidates.find(c =>
EMOTION_PRIORITY[c.state] < EMOTION_PRIORITY[currentMood]
)
if (deescalation) return deescalation
// 否则选择优先级最高的候选
return candidates.sort((a, b) =>
EMOTION_PRIORITY[b.state] - EMOTION_PRIORITY[a.state]
)[0]
}核心原则:缓解当前负面情绪的事件,优先级高于制造新情绪的事件。用户明显在道歉,你不应该因为他顺带提了个名字就无视道歉直接吃醋。
防抖动
用户连续发消息时,情绪不应该在短时间内反复切换(比如 1 秒内从开心→生气→害羞→平静)。解决方案是引入冷却期(Cooldown):状态刚切换后的 N 秒内,不响应新的转移触发。
// index.ts
const COOLDOWN_MS = 5000 // 5秒冷却期
function canTransition(state: AgentState): boolean {
const elapsed = Date.now() - new Date(state.moodTimestamp).getTime()
return elapsed >= COOLDOWN_MS
}7. 总结
这篇文章设计了 AI 伴侣的情绪系统,让 AI 女友从"无状态的文本生成器"变成"有情绪记忆的陪伴者"。
关键要点:
- 情绪状态机独立于 LLM 运行,由应用层维护,每次对话时将当前情绪注入 Prompt
- FSM 模型:6 种状态(平静 + 5 种情绪),确定性转移规则,可预测、可调试、可持久化
- LangGraph 实现:情绪分类器节点判断用户意图 → 查转移规则表 → 条件路由到不同的回复节点
- 亲密度与情绪联动:亲密度影响情绪触发阈值,情绪事件反向影响亲密度数值
- 边界处理三件套:时间衰减(半衰期模型)、冲突解决(优先级 + 缓解优先)、防抖动(冷却期)
下一篇我们将讨论 Prompt 工程的深度实践,探索如何设计分层的 System Prompt 架构,让不同情绪模式下的 AI 回复既人格一致又风格多变。