ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

对话系统用户画像实时更新:从触发条件到架构落地的完整实践

对话系统用户画像实时更新:从触发条件到架构落地的完整实践

做对话系统的朋友应该都有类似的体验:上线第一周,用户觉得你聪明;跑了一个月,用户开始嫌你“记性差”。问题往往不是模型不够强,而是用户画像一直没跟上。OpenClaw 这个对话系统项目早期版本里,用户画像基本是离线维护的,运营同学每周导出一批标签,再统一灌进系统。这种节奏在单轮问答里看不出毛病,一旦进入多轮对话和个性化推荐,体验就开始崩了。用户上午刚说自己“正在准备考试”,下午系统还在给他推荐娱乐内容,这谁受得了。所以“对话系统是否支持用户画像实时更新,更新触发条件怎么设计”就成了项目迭代里绕不开的硬骨头。

这篇文章就把我们当时在 OpenClaw 项目里踩过的坑、试过的路、最后沉淀下来的方案一起拆开讲清楚。适合正在做对话系统、智能客服、个性化推荐的同学参考,也适合刚接触用户画像、想搞明白实时更新到底怎么回事的产品和技术同学读。

1. 画像实时更新不是“炫技”,是对话体验的基本盘

1.1 用户画像是对话系统的“记忆层”

用户画像在对话系统里不是一句空话,它本质上是一组结构化的标签集合,用来描述“这个用户是谁、最近在关心什么、此刻处于什么状态”。它处在对话管理器和知识库之间,解决的是记忆问题。模型负责“听懂话”,画像负责“记得事”。

OpenClaw 的对话流程里,每轮用户输入进来,先经过意图识别和实体抽取,再由对话管理器决定怎么回复。这中间有很大一部分决策依据来自画像:如果画像里写着“新用户、还没有购买记录”,推荐话术就走转化方向;如果写着“高活跃、最近关注露营装备”,回复就会更接近老友推荐。没有画像,对话系统就是失忆的,每一轮都像第一次见到这个用户。

但问题恰恰出在这里:用户画像是会过期的。人的兴趣会漂移,状态会切换,需求会变化。一个静态画像跑两周,基本上就是在用三个月前的假设回答今天的问题。这时候实时更新就不是锦上添花,而是能不能维持基本体验的问题了。

1.2 “实时更新”到底指什么

很多人一听到“实时更新”就以为要每次对话都全量重算画像,这是把问题想大了。真实场景中,不同标签的时效要求差异非常大。

“用户所在城市”这种标签,半小时更新一次都嫌频繁;“用户当前情绪状态”这种标签,必须在本轮对话内生效;“用户是否正在比较竞品”这种上下文意向标签,只能在下一轮对话开始前刷新。把这几种标签放在同一个“实时”概念里,是很多方案跑偏的根源。

我们当时在 OpenClaw 项目里定的目标是:状态型标签秒级生效,偏好型标签分钟级生效,统计型标签小时级或天级回刷。这个目标直接决定了后面的架构选型,也避免了“所有标签都要毫秒级”这种不切实际的设计。所谓的“实时”,是在一个业务可接受的延迟窗口内,把新增信息增量合并进去,而不是不计成本地追求绝对零延迟。

1.3 离线批处理和实时增量不是二选一

还有一种很常见的误区是把实时和离线对立起来。我见过有团队为了“实时”把近 30 天的统计标签也全量重算,结果每次更新都要扫几百万条日志,集群负载高得离谱,标签还未必比 T+1 准多少。

为什么?因为像“最近一个月的活跃天数”这种指标,本质是统计量,对实时性的需求没那么强;而“用户今天问了三次股价”这种状态,才是需要立刻记下来的东西。把不同类型的标签混在一个更新策略里,纯属给自己找麻烦。

更务实的做法是混合:实时事件流负责状态型标签的增量更新,离线批处理负责统计型标签的周期回刷。OpenClaw 后续版本同时保留两张表——在线画像表和离线画像表,两侧各自更新,读取时做合并。这样让“快”和“全”共存,而不是互相迁就。

提示:在动手设计画像更新之前,先做标签分类,把标签按“状态型、偏好型、统计型”分好,再分别定更新频率和计算方式。这一步能帮你省掉后面大量返工。

2. 更新触发条件:什么样的信号才有资格改动画像

2.1 事件型触发:用户“做”了什么

最直接的触发信号是用户行为事件。OpenClaw 对话系统会接入三类事件:对话内事件(用户说了什么、点了什么按钮)、埋点事件(页面点击、停留时长)、业务事件(下单、退款、注册)。

不是所有事件都要触发更新,而是要看事件与画像字段之间有没有明确的映射关系。我在项目里维护了一张“事件-标签映射表”,这张表是整个画像系统的规则核心,比模型还重要。

事件类型示例对应画像字段初始置信度
主动声明“我是学生”“我住在上海”身份、城市0.95
深度点击点击商品详情并停留超过 5 秒品类偏好0.6
高频询问连续两次问同一个功能兴趣标签0.5
负向事件投诉、反复进入帮助中心风险标签0.7

更新触发里最忌讳的就是“有事件就更新”,那样画像会被噪音打穿。比如用户误触了一个链接,如果立刻触发偏好标签更新,后面几轮推荐都会偏。所以事件触发一定要挂上置信度门槛,而且置信度要跟事件强度挂钩。

2.2 语义型触发:从对话内容里“听”出来的信号

第二类触发信号来自对话内容本身。OpenClaw 的多轮对话里,每一轮用户的自然语言都会被做意图和实体抽取,抽出来的东西天然就是画像候选。比如用户说“帮我找一个适合跑步戴的耳机”,意图是“商品推荐”,实体是“跑步”,那“运动偏好=跑步”这个标签就有了一次加权。

但这里有一个关键问题:置信度怎么算?我们当时的做法是组合打分。用户主动提及且带有明确动词,比如“我喜欢”“我需要”,置信度给到 0.8;如果是第三人称,比如“我朋友需要”,置信度就要降到 0.3,还要挂一个“关系=朋友”的上下文,不能直接写进这个用户自己的偏好里。

语义型触发的最大坑,是把假设当事实。用户说“我想减肥”,不代表他长期关注健身,可能只是一时兴起。所以我们给语义触发的更新加了一个“观望期”:第一次命中只加一点权重,不直接生效,等第二次或第三次出现才正式落库。这个机制对防止画像漂移非常管用。

2.3 时间型触发:定时刷新和遗忘机制

画像标签不能只增不减,时间本身也是一种触发条件。我们当时给 OpenClaw 的画像体系引入了两个时间相关的机制:过期和衰减。

过期是指某个标签有明确的 TTL。比如“备考雅思”这个标签,设定有效期为 90 天,到期后自动降权直到删除。衰减则是模拟人的记忆曲线,一个标签如果长期没有被强化,权重会按指数衰减。具体算法不复杂:新权重 = 原权重 × 0.5^(天数 / 半衰期),半衰期按标签类型配置,强偏好标签设 60 天,临时意图标签设 7 天。

这套机制解决了一个非常真实的问题:用户三个月前疯狂搜索露营,系统一直给他推露营装备,但他其实早就转向跑步了。没有遗忘机制的画像,越更新越失真。实时更新不只是“写新信息”,也要负责“清理旧信息”,这两个动作是同一个硬币的两面。

2.4 显性反馈型触发:用户自己动手“修正”

最后一种触发往往容易被技术团队忽略:用户的显性反馈。OpenClaw 在做个性化推荐的时候,会给结果附带“不感兴趣”“少推这类内容”的操作入口。用户点了“不感兴趣”,这就是一个高置信度的负反馈信号,对应标签的权重应该被明显下调甚至移除。

同理,用户在账号设置里主动更改偏好设置,比如把兴趣从“汽车”改成“数码”,这类信号的优先级应该高于任何模型推断。产品经理常把这种操作叫“用户自己告诉系统答案”,它比任何算法都准。

我们在设计优先级时定了一条规则:显性反馈 > 事件触发 > 语义触发。为什么?因为显性反馈是用户主动表达的意图,几乎没有噪声;语义触发的噪声最大;事件触发居中。这条优先级规则在后来的排查里帮了很大的忙,很多画像漂移问题,最后发现都是因为把语义触发的低置信度结果当成了高优先级来写。

注意:在优先级设计上,一定要给“用户主动修正”留一个最高优先级的通道。否则模型再智能,也比不过用户一句“不对,我不喜欢”来得准确。

3. 实操落地:OpenClaw 的画像实时更新管道是怎么搭出来的

3.1 整体链路设计

我们的最终方案是一条“事件驱动 + 规则计算 + 异步写库”的管道。对话服务产生的事件,先经过格式化变成标准事件体,然后打入消息队列;轻量实时计算节点消费事件,执行规则匹配和置信度打分,结果写入在线画像存储;对话服务在下一次会话开始时读取最新的画像。

为什么要在中间加一个消息队列?直接改画像库不是更快吗?这里有两个原因。一是削峰:对话服务在高峰期可能同时产生大量事件,如果直接写画像库,容易把存储打爆;二是解耦:对话服务不需要关心画像更新的细节,只要把事件发出去就行,画像逻辑再怎么改,对话服务都不用动。

实时计算节点当时用了规则引擎加少量统计脚本,没有上重型的流计算框架。原因很简单:我们的触发条件以规则为主,规则引擎足够覆盖 90% 的场景,重型框架的运维成本对我们来说太高了。在体量没有大到一定程度之前,简单方案往往是最稳的。

3.2 画像存储的数据结构

画像存储的字段设计比很多人想象中要复杂。每个标签不是简单地存一个值,而是要连同置信度、更新时间、来源、有效期一起存。下面是我们当时用的简化结构:

{ "profile_id": "u_1024", "labels": { "city": { "value": "上海", "confidence": 0.95, "updated_at": "2025-01-12 10:00:00", "source": "user_declare", "ttl": null }, "interest_camping": { "value": 0.3, "confidence": 0.6, "updated_at": "2025-01-11 14:30:00", "source": "semantic", "ttl": "2025-03-12" }, "intent_buy_earphone": { "value": 0.8, "confidence": 0.75, "updated_at": "2025-01-12 09:00:00", "source": "event", "ttl": "2025-01-19" } } }

这个结构里,value 不一定只存布尔值或字符串,有些标签存的是一个数值权重。confidence 表示这个标签的可信度,updated_at 记录最后更新时间,source 用于追溯标签来源,ttl 配合遗忘机制使用。

为什么要有 source?因为排查问题时需要知道这个标签是哪里来的,是用户主动说的,还是模型推断的,还是行为事件累加的。有一次线上画像突然出现一个莫名其妙的标签,我们靠 source 字段一眼就定位到是一条规则写错了。没有这个字段,排查成本会高很多。

3.3 核心实现:事件消费和标签更新流程

事件消费的核心逻辑并不复杂,难在规则的组织和置信度合并策略。我们当时用一个 Python 写的规则引擎来做,核心代码大概长这样:

def on_event(event): for rule in match_rules(event): label = rule.label delta = rule.compute_delta(event) old = profile.get(label) if rule.strategy == "accumulate": # 累加法:1 - (1-old) * (1-delta),保证结果在 0~1 之间 new_conf = 1 - (1 - old.confidence) * (1 - delta) elif rule.strategy == "overwrite": # 覆盖法:用户显性更正时用 new_conf = delta if new_conf >= rule.apply_threshold: profile.set( label, confidence=new_conf, source=rule.source, ttl=rule.ttl )

置信度合并是个容易被低估的细节。累加法和覆盖法要分场景用:事件触发通常用累加法,让置信度慢慢爬升;用户显性反馈和人工修正必须用覆盖法,直接指定新值。

写入方面有几个小技巧。为了避免每条事件都单独写一次存储,我们采用固定窗口批量合并,比如攒够 100 条事件或者每 5 秒批量写一次。实测下来,这种方式在吞吐和实时性之间平衡得很好,不会明显增加延迟,又能大幅降低存储压力。

3.4 与对话管理器的协同:读写分离

对话管理器在读画像时,用本地缓存加远程 KV 回源。画像更新的写入是异步的。一个常见的错误是在对话流程里同步更新画像,导致用户这一轮的响应延迟增加。我们后来把所有画像更新都挪到了对话结束后异步提交。

但这里有一个例外:如果用户在这一轮明确说“不对,我不喜欢”,这是一次高优先级的覆盖更新,必须同步写,并且要立即把对话管理器本地缓存的对应标签失效掉。因为用户已经明确表达了否定,系统必须在这个会话内立刻调整后续话术,否则用户第二次感受到“怎么我说不喜欢你还推”,信任感会直接崩掉。

# 对话结束后提交画像更新,不阻塞主流程 dialogue_result = dialogue_manager.process(user_input) async_submit_profile_events(dialogue_result.events)

这是我们见过的唯一一种需要同步写画像的场景。把它单独拎出来处理,既保证了常规路径的响应速度,又照顾到了用户直接纠错的强需求。

4. 常见问题与排查技巧实录

4.1 画像漂移:越更新越不准

症状:一个用户因为两周内连续搜索婚礼相关话题,被系统打上了“备婚人群”标签,后续推荐全是婚礼摄影,但用户实际是帮朋友筹备。

排查思路:先看这个标签的 source、加权次数和更新时间。结果发现是同一个会话内,用户连续三次提到了婚礼相关词,语义触发的高频信号直接把长期偏好标签给打满了。问题出在“高频”被当成了“长期”。

对策:对强偏好标签引入“连续性校验”,要求标签在两个不同会话中命中且间隔超过一天,才允许升为长期标签。同时给每个标签设定单日累加上限,比如一天最多累加 0.3,防止短期集中事件把置信度推得过快。

4.2 并发更新导致旧数据覆盖新数据

症状:用户先看了手机,又看了汽车,两条事件几乎同时到达,最后画像里只剩下一个品类的标签,另一个消失得无影无踪。

排查思路:检查画像表的更新时间戳,发现同一秒内有两条写操作,但只有一条留下来了。典型的读-改-写竞态:两条更新同时读到旧值,各自计算后写回,后写的覆盖了先写的。

对策:使用 KV 存储的原子操作或 Lua 脚本,在服务端完成合并,而不是在应用层完成读改写。也可以给每次更新带一个递增版本号,旧版本不覆盖新版本。这里提醒一句,高并发场景下千万不要在应用层裸读写,我第一次调试这个问题时蹲了整整一个下午。

4.3 实时更新拖垮了对话响应

症状:高峰期对话响应 P99 从 300ms 涨到 900ms,用户明显觉得机器人“变笨了”。

排查思路:用链路追踪看了半天,发现对话管理器在每一轮都同步等待画像更新完成。更新逻辑虽然本身不慢,但高峰期图片存储和队列稍有抖动,就把对话主路径给卡住了。

对策:改为异步写加缓冲批量写,把非关键标签全部降级为分钟级合并,对话路径上只保留读操作。实测下来,P99 回到了 350ms 左右,画像更新的积压量靠消息队列自然消化,高峰过后并不会堆积。

4.4 冷启动:新用户画像全空怎么办

症状:新用户第一次进入对话,画像表里什么都没有,系统只能给出完全通用的回复,个性化完全失效。这时候实时更新系统再快,也没有数据可以更新。

对策:我们在画像里加了一层“默认画像”,来自账号注册信息和设备静态属性,比如城市、设备类型、新老用户状态。对话时先用情绪识别和当轮意图生成临时标签,让第一轮回复不至于太“瞎”。等用户产生足够事件后,再正式写入偏好型标签。

一个小的实测建议是,在冷启动期不要用高置信度阈值拦截,而是“低门槛接住、高门槛落地”。什么意思?就是先用宽松规则把可能的偏好先记下来,但要到足够多的证据时才把它升级成长期标签。这样既不会在第一轮就表现得太迟钝,也不会被几句话误导成长期画像。

写在最后

最后说一点个人体会。在整个 OpenClaw 画像实时更新系统的实践里,最让我意外的不是算法,也不是存储选型,而是“触发条件”的设计远比想象中重要。事件触发、语义触发、时间触发、显性反馈触发,四类信号混在一起,规则稍微不严密,画像就会变成一团浆糊。

我的建议是先把“不做什么”定义清楚:哪些信号不更新、哪种置信度不够不落库、哪类标签需要二次确认。把这些边界定好,比一次把“做什么”设计完整更重要。因为边界的缺失会直接导致画像被噪声淹没,线上问题层出不穷。

如果团队也要做类似功能,从一张“事件-标签映射表”开始,用两周时间把最核心的二十条规则跑通,观察线上坏例,再逐步扩展。这个节奏比一次性上一整套全面的画像系统要稳得多,也更容易让上下游团队理解画像到底在干什么。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进