ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

强化学习对话策略:黄页微聊商机引导的DQN到Dueling DQN落地

强化学习对话策略:黄页微聊商机引导的DQN到Dueling DQN落地 简介这份PDF是58同城算法架构师王勇的技术分享实录面向算法工程师、对话系统研发者与智能客服产品从业者聚焦强化学习在黄页商家智能聊天助手「帮帮商家版」中的落地实践适合具备一定机器学习与任务型对话基础的中高级读者参考。内容涵盖业务背景与商机流失痛点梳理NLU、DM、DST、DP、NLG组成的任务型对话架构对比基于节点配置与基于强化学习两种对话管理的差异并系统回顾智能体、状态、动作、策略、贝尔曼方程等强化学习基础概念。文中还展开DQN、Nature DQN、DDQN、Dueling DQN的模型选择与优化思路给出转化率与流畅度双指标、奖励函数设计、特征构造及离线评估方法等关键细节。资源包共1个PDF文件体积约4.83MB为单文档深度技术报告。目前已有54人学习适合希望理解强化学习如何驱动多轮商机引导策略的读者研读。1. 黄页商家的微聊窗口里最贵的一句话该由谁来挑黄页商家的微聊窗口里最贵的一句话不是「您好」而是「方便留个手机号吗」。58 同城做帮帮商家版微聊代运营时把这句话交给强化学习模型来挑而不是交给运营在后台配节点——这个决定在当时挺反直觉的因为节点配置看得见、改得快、出事能立刻回滚。但上线一段时间后问题很一致流程固定、看不到上下文、问法多样性差配置表越堆越长同一个「马桶堵了」在三条流程里各写一遍。黄页商家智能聊天助手的落点其实很窄强化学习只替换对话管理DM里「槽位填充的询问过程」NLU、对话状态追踪、NLG 全部保留原样。这样改动可控也适合已经有稳定意图体系的团队。商机引导的目标也单纯——服务 C 端用户提升体验服务 B 端商家拿到联系方式、地址、时间这些关键槽位最终促成交。下面按照一份真实落地方案的拆解顺序走先把一轮微聊写成马尔可夫决策过程再定 Q 网络接着解决稀疏奖励和离线评估最后落在联合学习和策略兜底上。DQN 那一串变体Nature DQN、Double DQN、Dueling DQN为什么要逐个换每个换完解决什么都会给到代码和参数。2. 商机引导的 MDP 建模状态特征、动作空间与奖励函数怎么定2.1 节点式 DM 的槽位填充链路与它的三条边界节点式对话管理用五类节点拼流程StartNode 是 Taskbot 入口TriggerNode 按意图和槽位决定走哪条分支SlotNode 控制这轮该问哪个槽位ResponseNode 控制话术EndNode 表示会话结束。系统按照配置不断交互直到槽位填满或用户离开。这套东西的配置长这样# 节点式 DM 的配置片段简化示意每条边都由运营手写 flow { StartNode: {next: TriggerNode.ServiceType}, TriggerNode.ServiceType: { # 命中维修意图且服务类型已填 - 直问地址否则先问服务类型 cond: intent 需要维修服务 and slot.service_type is not None, next: SlotNode.Address, else: SlotNode.ServiceType, }, SlotNode.ServiceType: {ask: 您需要什么服务呢, next: SlotNode.Address}, SlotNode.Address: {ask: 您在哪个区呢, next: SlotNode.Time}, SlotNode.Time: {ask: 大概什么时候方便, next: SlotNode.Phone}, SlotNode.Phone: {ask: 方便留个手机号吗, next: EndNode}, EndNode: {next: None}, }这段配置暴露了三个问题。第一流程固定、个性化缺乏想加一个「服务对象老人/儿童」槽位就要回头改 TriggerNode 的分叉优先级改一处牵动三条边。第二没用到上下文信息多样性差用户上一轮说过「我这会儿不在家」配置里的下一句仍然是「大概什么时候方便」机器人听不见历史。第三复杂流程配置本身就是成本本地服务类目几十个每个类目一套图运营和算法互相等对方。2.2 把一轮微聊写成五元组状态怎么拼、动作怎么枚举强化学习接管的部分只有「问什么」建模时状态特征要覆盖两件事用户说了什么以及对话进行到哪一步。方案里用的特征包括 n 轮用户历史 query、n 轮 taskbot 历史 query、上一轮识别出的商机类型、本轮用户 query以及 taskbot 历史 query 的类型。经验上「上一轮识别商机类型」换成「历史识别商机类型序列」效果更好因为商机类型在对话中途会漂移。特征块典型维度来源备注用户历史 query 句向量768 × n 轮已收敛的 NLU 编码器训练时冻结冻结是为了让 state 分布稳定taskbot 历史话术类型动作数 × n 轮上一轮策略的 action反映「已经问过什么」历史商机类型意图数多轮商机识别模型单轮识别抖动大建议用序列槽位填充状态槽位数DST 模块one-hot 即可别塞原始文本对话轮次1环境自带微聊十几轮就结束轮次是强信号动作集不用做成「生成一句话」而是做成「话术类型」。这是整个落地里最关键的一次降维# 动作集 机器人每轮可以问的话术类型来源是人工客服日志里统计出来的高频问法 ACTION_SPACE [ ask_service_type, # 需要什么服务 ask_service_target, # 给谁服务老人/儿童/宠物 ask_address, # 在哪个区 / 哪个小区 ask_time, # 什么时候方便 ask_contact, # 留个手机号 / 微信 ask_budget, # 预算大概多少 confirm_slot, # 复述确认已填槽位 ] ACTION2ID {a: i for i, a in enumerate(ACTION_SPACE)} ID2ACTION {i: a for a, i in ACTION2ID.items()}动作是类型而不是措辞好处有两个动作空间从无穷句压缩到十来个离散标签Q 网络的输出层才装得下具体措辞交给下游 NLG 的模板或生成模型策略只负责决定这轮该要什么信息。参数上要注意动作数直接决定输出层维度中途新增动作意味着最后一层要重训所以动作集在项目初期就得定死别上线后再加。2.3 奖励函数把转化率和流畅度压成一个标量奖励设计锚在两个业务指标上转化率获取关键商机的会话数 / 总会话数和流畅度流畅会话数 / 总会话数。落到单轮 reward就是把「和人工客服选得一样不一样」和「最后有没有留下商机」两个维度线性组合情形action 与人工客服一致是否拿到商机建议 rewardA是是1.0B是否0.2C否是0.0D否否−0.5def compute_reward(action, expert_action, got_lead, slot_filled): 单轮 reward流畅度维度 转化率维度 r 0.0 # 流畅度维度同一状态下是否和人工客服选了同一类话术 r 0.3 if action expert_action else -0.2 # 转化率维度会话结束时是否拿到关键商机联系方式 if got_lead: r 0.7 elif slot_filled: r 0.1 # 中间槽位填上了给一点塑形奖励 else: r - 0.3 return r注意商机只在会话末尾产生前面十几步 reward 全是 0这是典型的稀疏奖励。直接把末端 reward 只挂在最后一步「询问联系方式」这个动作的价值永远学不出来。常见做法是末端 reward 折扣回填到「询问联系方式」那一步或者像上面一样给中间槽位一个小额塑形奖励。奖励数值本身没有理论最优靠离线评估里「会话完成度」和「平均 return」两个指标来回调。一致性权重给太大模型会退化成模仿人工客服转化率权重给太大模型可能反复追问联系方式流畅度掉下来。3. DQN 到 Dueling DQN动作有限时对话策略网络的选型与实现3.1 动作有穷为什么先上 DQN 而不是策略梯度动作空间是十来类话术类型离散且有穷每轮既要评估「这个动作值多少」又要取「值最大的那个动作」。DQN 的输出天然就是每个动作的 Q 值向量取 argmax 即得策略顺带还能做动作屏蔽——没填完地址就不允许问联系方式直接把对应维度的 Q 置成负无穷。策略梯度类方法得采完一整条轨迹才能更新方差大、样本效率低而微聊日志的量和标注质量通常撑不起这种采样开销。3.2 Nature DQN 的目标网络与「难收敛」的真因最朴素的 DQN 用同一个网络既算 Q target 又算当前 Q 值target 跟着参数一起动等于在追一个自己也在跑的靶子相关性太强训练曲线会来回摆甚至发散。Nature DQN 的解法是拆成 online 网络和 target 网络target 网络每隔若干步从 online 硬拷贝一次参数把靶子钉住一段时间。这一步在商机引导这种短回合任务上收益最明显因为一条会话才十几步target 抖动会被放大。3.3 Double DQN 与 Dueling DQN 各自改了什么Nature DQN 还有一个残留问题选动作和算价值用的是同一个 target 网络选出来的那个动作天然带着乐观偏差导致 Q 值被系统性高估。Double DQN 把两件事分开动作由 online 网络选价值由 target 网络算。黄页场景里高估的后果很具体——模型会过度自信地反复问联系方式用户被问烦了直接退出。Dueling DQN 动的是网络结构把 Q 拆成价值函数 V(s) 和优势函数 A(s,a) 两支V 只和状态有关A 描述在这个状态下每个动作相对好多少。合并时做中心化处理保证 Q 的可辨识性。商机引导里大量状态其实「选哪个动作差别不大」比如用户刚说完需求、信息都还没填V 支先把状态本身的价值压准A 支再区分细微差别收敛速度比单支网络快。变体Q target 计算方式网络结构解决的问题商机引导里的表现DQN用同一网络算 target 和更新单支无难收敛曲线震荡Nature DQN定期拷贝的 target 网络单支target 相关性能收敛偏乐观Double DQNonline 选动作 target 算价值单支Q 值高估追问行为更克制Dueling DQN同 DDQNV A 双支状态价值学得慢会话完成度提升明显3.4 商机引导的 Q 网络实现把状态编码器和 Dueling 头分开写是为了让 NLU 编码器保持冻结避免 state 分布跟着 NLU 一起漂移。import torch, torch.nn as nn, torch.nn.functional as F class StateEncoder(nn.Module): 把原始对话上下文压成一个定长 state 向量 def __init__(self, text_dim768, n_turn3, n_slot8, n_intent12, out_dim256): super().__init__() # 用户历史 query 句向量n_turn 轮编码器训练时冻结 self.user_fc nn.Linear(text_dim * n_turn, 192) # taskbot 历史问句的「话术类型」one-hotn_turn 轮 self.bot_fc nn.Linear(len(ACTION_SPACE) * n_turn, 48) # 历史商机类型 槽位填充状态 self.ctx_fc nn.Linear(n_intent n_slot, 32) self.out nn.Linear(192 48 32, out_dim) def forward(self, user_vec, bot_type_onehot, ctx_onehot): h torch.cat([ F.relu(self.user_fc(user_vec)), F.relu(self.bot_fc(bot_type_onehot)), F.relu(self.ctx_fc(ctx_onehot)), ], dim-1) return torch.relu(self.out(h)) class DuelingQNet(nn.Module): def __init__(self, state_dim256, n_actionslen(ACTION_SPACE), hidden256): super().__init__() self.trunk nn.Sequential(nn.Linear(state_dim, hidden), nn.ReLU()) # 价值函数只与 state 有关 self.value nn.Sequential(nn.Linear(hidden, 128), nn.ReLU(), nn.Linear(128, 1)) # 优势函数与 (state, action) 有关 self.adv nn.Sequential(nn.Linear(hidden, 128), nn.ReLU(), nn.Linear(128, n_actions)) def forward(self, s): h self.trunk(s) v, a self.value(h), self.adv(h) return v a - a.mean(dim1, keepdimTrue) # 中心化保证 Q 可辨识 def td_loss(online, target, batch, gamma0.9): Double DQN 目标 Huber 损失 s, a, r, s2, done batch q online(s).gather(1, a) with torch.no_grad(): a_star online(s2).argmax(dim1, keepdimTrue) # 选动作online 网络 q_next target(s2).gather(1, a_star) # 算价值target 网络 y r gamma * (1.0 - done) * q_next return F.smooth_l1_loss(q, y)gamma取 0.9 而不是常见的 0.99是微聊任务的特点决定的一条会话十几步就结束折扣因子太大末端商机奖励会被中间步骤稀释Q 值区分度下降。损失用smooth_l1_lossHuber而不是 MSE是因为商机 reward 的离群值比较多——一笔成单可能带来远超均值的长尾回报。另外gather之前要先做动作屏蔽把非法动作的 Q 置成-inf否则 argmax 会选出一个流程上不允许的动作。4. 离线训练与评估会话完成度、平均 return 与业务口径对齐4.1 训练样本构造与经验回放样本从人工客服和旧版节点式 DM 的历史日志里挖把每条真实会话按轮切开每一轮的 (state, action, reward, next_state, done) 作为一条转移塞进回放池。人工客服当轮实际说的话术类型作为expert_action用于奖励函数的一致性判定。from collections import deque import random class ReplayBuffer: def __init__(self, capacity200_000): self.buf deque(maxlencapacity) def push(self, s, a, r, s2, done): self.buf.append((s, a, r, s2, done)) def sample(self, batch_size128): batch random.sample(self.buf, batch_size) s, a, r, s2, done zip(*batch) return (torch.stack(s), torch.tensor(a).unsqueeze(1), torch.tensor(r, dtypetorch.float32).unsqueeze(1), torch.stack(s2), torch.tensor(done, dtypetorch.float32).unsqueeze(1))回放池的作用不只是打散相关性。对话日志里「问地址」的样本远多于「问预算」正样本比例失衡随机采样等于给高频动作加权重。实践中会在sample里按动作做分层抽样让每个动作每批至少出现几条。done标记也不能马虎用户中途退出和会话正常结束是两种结束方式前者不该给末端商机奖励混在一起会让模型学出「拖着不问就没损失」的坏习惯。4.2 离线评估指标与计算脚本评估用两个模型侧指标会话完成度 完成的完整会话数 / 所有会话数平均 return 轨迹 reward 累加值的平均。再把业务口径的转化率、流畅度并到一起看四个数一起动才算健康。def offline_eval(policy, sessions, gamma0.9, max_turns20): 离线评估只做前向不改参数 total_return done_cnt lead_cnt smooth_cnt 0 n len(sessions) for sess in sessions: state, ret, finished sess[initial_state], 0.0, False for t in range(max_turns): with torch.no_grad(): action int(policy(state).argmax(dim-1)) # 用日志重放代替真实环境state 转移由真实用户行为决定 reward, state, done, info sess[env_step](action) ret (gamma ** t) * reward lead_cnt int(info.get(lead, False)) smooth_cnt int(info.get(smooth, False)) if done: finished True break total_return ret done_cnt int(finished) return { 会话完成度: done_cnt / n, 平均return: total_return / n, 转化率: lead_cnt / n, 流畅度: smooth_cnt / n, }max_turns卡在 20 是有意的真实微聊超过 20 轮基本等于用户已经放弃继续回放只会把平均 return 拉平。gamma必须和训练时一致评估和训练的折扣因子不一致是最容易犯的低级错误指标会莫名其妙对不上。指标口径看什么异常信号会话完成度完整会话数 / 总会话数流程是否走得下去低于基线说明策略在中间卡死平均 return轨迹 reward 累加 / 会话数策略整体优劣高但转化率低说明在刷一致性奖励转化率获取关键商机会话数 / 总会话数商机引导效果追高时留意流畅度是否被牺牲流畅度流畅会话数 / 总会话数与人工话术的一致性掉太多会被业务方直接拒收4.3 离线评估的偏差与上线前的交叉验证离线评估最大的局限在于状态转移由日志里的真实用户行为决定但新策略走出的动作分布已经和产生日志的旧策略不同。日志里没出现过的 (state, action) 组合评估脚本只能给出一个外推的 Q 值这个数不可信。几种补法一起上更稳统计动作覆盖率把新策略在评估集里选到「日志中从未出现」的动作比例打出来超过阈值就先别上用重要性采样对新旧策略的动作概率比做加权压低高偏差样本的权重再抽一批会话做人工一致性抽查看机器人选的话术类型和人工客服是不是同一档。上线前还有一层验证不能省——影子模式。新策略只做预测不真正影响用户把它和线上节点式 DM 的动作并排打在日志里跑够一周再看分歧率。分歧率高且转化率没提升说明模型学到的只是噪声。5. 联合学习、冷启动与策略兜底把人工话术当先验商机引导的 return 信号太稀疏光靠 RL 从零学前期会经历一段「乱问」的阶段。方案里用的是联合学习把 return 最大化和「话术类型分类」两个目标拼在一个损失里分类任务的 label 就是人工客服当轮选择的话术类型梯度稠密得多。def joint_loss(online, target, batch, expert_action, lam0.3, gamma0.9): RL 目标 人工话术模仿目标 l_td td_loss(online, target, batch, gamma) s batch[0] # 复用 Q 输出当 logits 做交叉熵label 是人工客服当轮的话术类型 l_bc F.cross_entropy(online(s), expert_action) return l_td lam * l_bc权重lam建议做 warmup 而不是全程固定前期给到 0.5 甚至 1.0让策略先学会说人话、把会话完成度拉起来等完成度稳定后降到 0.1 以下把优化重心还给 return。这个切换点得看指标曲线不能按步数硬切。冷启动阶段更省事的做法是先用行为克隆预热——纯监督学人工客服的 (state → action) 映射训到策略和人几乎重合再切到联合学习。这样初始策略的探索不会离谱到让用户直接退出。策略兜底是上线前必须加的一层尤其是兜底规则要写成白名单而不是黑名单触发条件兜底处理argmax 动作不在当前可问槽位白名单退回到节点式 DM 的下一个未填槽位Q 值 top1 与 top2 差值小于阈值选与人工客服先验分布一致的那个连续两轮未得到任何槽位填充切人工客服或结束会话用户明确表示拒绝提供联系方式屏蔽ask_contact转入其他槽位验证这套东西最实用的技巧是拿「动作序列」而不是单轮准确率做对比把模型在线跑出的一条会话和同一场景下人工客服的话术类型序列做编辑距离距离小的说明节奏像人。单轮准确率高的模型经常在整体节奏上是散的——每轮都对连起来不像一个客服在说话。最后补一句关于 Dueling 结构的调参经验V 支和 A 支的学习率没必要一致A 支收敛更慢、噪声更大给它单独降一档学习率会话完成度的曲线会平得多。本文还有配套的精品资源点击获取
返回列表