用户反馈收集系统——把用户信号转化为 AI 改进
用户反馈收集系统——把用户信号转化为 AI 改进
适读人群:AI 产品工程师 / 想持续改进 AI 质量 | 阅读时长:约16分钟 | 核心价值:完整的用户信号收集体系,把行为数据转化为 AI 改进方向
产品上线第一个月,我们只有一个反馈机制:AI 回答下面一个点赞、一个踩的按钮。
一个月后,点赞总数 1274,踩总数 89,点赞率 93%。我们很开心,觉得 AI 质量不错。
然后我们看了用户行为视频,发现一个让我们困惑的现象:有相当多的用户看完 AI 回答后,直接关掉了对话,去用搜索引擎重新搜了一遍。
这说明什么?说明这些用户对 AI 的回答不满意,但他们没有踩,可能是懒得踩,可能是觉得踩了也没用,可能是根本没注意到那个踩按钮。
点赞/踩按钮只收集了愿意反馈的用户的信号,而这类用户只是少数。大量不满意但沉默的用户,我们完全看不见。
从那以后,我开始认真设计用户反馈收集系统,不只是按钮,而是完整的用户信号体系。
用户信号的四个层次
我把用户信号分成四层,重要性和获取难度都不同:
第1层:显式反馈(用户主动给出)
- 点赞/踩
- 评分(1-5星)
- 文字反馈
- 举报/标记错误
优点:意图明确。缺点:参与率低,通常只有 5-15% 的用户会主动反馈。
第2层:隐式采纳行为(用户用了没用)
- 用户复制了 AI 的回答
- 用户采纳了 AI 建议
- 用户把 AI 生成的内容发布出去
- 用户把 AI 回答分享给别人
这比点赞更有说服力:用户愿意把 AI 的内容用在实际工作中,说明他认为内容有价值。
第3层:隐式否定行为(用户没用)
- AI 回答后,用户立刻换了搜索词重新搜
- 用户对同一问题多次提问(对答案不满意)
- 用户在 AI 回答后短时间内关闭对话
- 用户采纳了 AI 建议后又完全删掉重写
第4层:业务结果信号(AI 对业务的影响)
- 使用了 AI 功能的用户,7/30 天留存是否更高
- 使用了 AI 建议的文章/内容,后续的互动数据
- AI 辅助完成任务的用户,任务完成率变化
完整的信号收集实现
事件追踪基础架构
@Component
@Slf4j
public class UserSignalTracker {
@Autowired
private EventStore eventStore;
@Autowired
private MeterRegistry meterRegistry;
/**
* 统一的信号记录接口
* 所有用户信号都通过这个接口记录
*/
public void track(SignalEvent event) {
// 异步写入,不阻塞主流程
CompletableFuture.runAsync(() -> {
try {
eventStore.save(event);
// 实时指标
meterRegistry.counter("user.signal",
"type", event.getType().name(),
"feature", event.getFeatureId(),
"sentiment", event.getSentiment() // positive/negative/neutral
).increment();
} catch (Exception e) {
log.error("Failed to track signal event", e);
// 信号丢失不影响主流程,只记录日志
}
});
}
// ===== 显式反馈信号 =====
public void trackExplicitRating(String userId, String aiResponseId,
int rating, String comment) {
track(SignalEvent.builder()
.type(SignalType.EXPLICIT_RATING)
.userId(userId)
.aiResponseId(aiResponseId)
.intValue(rating)
.textValue(comment)
.sentiment(rating >= 4 ? "positive" : rating <= 2 ? "negative" : "neutral")
.timestamp(Instant.now())
.build());
}
public void trackThumbsUp(String userId, String aiResponseId) {
track(SignalEvent.builder()
.type(SignalType.THUMBS_UP)
.userId(userId)
.aiResponseId(aiResponseId)
.sentiment("positive")
.timestamp(Instant.now())
.build());
}
public void trackThumbsDown(String userId, String aiResponseId, String reason) {
track(SignalEvent.builder()
.type(SignalType.THUMBS_DOWN)
.userId(userId)
.aiResponseId(aiResponseId)
.textValue(reason)
.sentiment("negative")
.timestamp(Instant.now())
.build());
}
// ===== 隐式采纳信号 =====
public void trackContentCopied(String userId, String aiResponseId,
String copiedContent) {
track(SignalEvent.builder()
.type(SignalType.CONTENT_COPIED)
.userId(userId)
.aiResponseId(aiResponseId)
.textValue(copiedContent)
.sentiment("positive")
.build());
}
public void trackSuggestionAccepted(String userId, String suggestionId,
double acceptanceRatio) {
// acceptanceRatio: 采纳了多少比例(部分采纳时不是 1.0)
track(SignalEvent.builder()
.type(SignalType.SUGGESTION_ACCEPTED)
.userId(userId)
.aiResponseId(suggestionId)
.doubleValue(acceptanceRatio)
.sentiment(acceptanceRatio > 0.7 ? "positive" : "neutral")
.build());
}
public void trackContentPublished(String userId, String aiResponseId,
double aiContributionRatio) {
// aiContributionRatio: 发布内容里有多少比例来自 AI
track(SignalEvent.builder()
.type(SignalType.CONTENT_PUBLISHED)
.userId(userId)
.aiResponseId(aiResponseId)
.doubleValue(aiContributionRatio)
.sentiment("positive")
.build());
}
// ===== 隐式否定信号 =====
public void trackQuickAbandonment(String userId, String aiResponseId,
long dwellTimeMs) {
// dwellTimeMs: 用户在 AI 回答上停留的时间
// 如果小于阈值(比如 3 秒),说明可能没有认真看
if (dwellTimeMs < 3000) {
track(SignalEvent.builder()
.type(SignalType.QUICK_ABANDONMENT)
.userId(userId)
.aiResponseId(aiResponseId)
.longValue(dwellTimeMs)
.sentiment("negative")
.build());
}
}
public void trackRepeatQuestion(String userId, String conversationId,
int repeatCount) {
// 用户对同一问题问了多少次
if (repeatCount >= 2) {
track(SignalEvent.builder()
.type(SignalType.REPEAT_QUESTION)
.userId(userId)
.conversationId(conversationId)
.intValue(repeatCount)
.sentiment("negative")
.build());
}
}
public void trackAcceptedThenDeleted(String userId, String suggestionId,
long timeBetweenMs) {
// 采纳了 AI 建议,但很快又删掉了
track(SignalEvent.builder()
.type(SignalType.ACCEPTED_THEN_DELETED)
.userId(userId)
.aiResponseId(suggestionId)
.longValue(timeBetweenMs)
.sentiment("negative")
.build());
}
}前端的隐式信号采集
class AiResponseTracker {
constructor(aiResponseId, userId) {
this.aiResponseId = aiResponseId;
this.userId = userId;
this.showTime = Date.now();
this.hasTrackedView = false;
}
// 用 IntersectionObserver 检测用户是否真的看到了 AI 回答
setupViewTracking(element) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.hasTrackedView) {
this.hasTrackedView = true;
this.viewStartTime = Date.now();
}
if (!entry.isIntersecting && this.viewStartTime) {
const dwellTime = Date.now() - this.viewStartTime;
this.trackDwellTime(dwellTime);
}
});
}, { threshold: 0.5 });
observer.observe(element);
}
trackDwellTime(dwellTimeMs) {
// 只有实际查看了超过 1 秒才上报(过滤误触)
if (dwellTimeMs > 1000) {
api.trackSignal({
type: 'DWELL_TIME',
aiResponseId: this.aiResponseId,
userId: this.userId,
dwellTimeMs: dwellTimeMs
});
// 停留少于 3 秒且回答超过 100 字,判断为快速放弃
const responseLength = document.getElementById('response-' + this.aiResponseId)
?.textContent?.length || 0;
if (dwellTimeMs < 3000 && responseLength > 100) {
api.trackSignal({
type: 'QUICK_ABANDONMENT',
aiResponseId: this.aiResponseId,
userId: this.userId,
dwellTimeMs: dwellTimeMs
});
}
}
}
// 监听复制事件
setupCopyTracking(element) {
element.addEventListener('copy', () => {
const selectedText = window.getSelection()?.toString();
if (selectedText && selectedText.length > 20) {
api.trackSignal({
type: 'CONTENT_COPIED',
aiResponseId: this.aiResponseId,
userId: this.userId,
copiedLength: selectedText.length
// 不上传具体内容,保护用户隐私
});
}
});
}
}信号分析与 AI 改进
收集到信号只是第一步,分析并转化为改进才是目的。
@Service
public class SignalAnalysisService {
/**
* 分析某个 AI 功能的健康度
* 这个方法可以每天运行,生成健康报告
*/
public FeatureHealthReport analyzeFeatureHealth(String featureId, Duration period) {
List<SignalEvent> events = eventStore.queryByFeature(featureId, period);
// 计算综合健康分(0-100)
double healthScore = calculateHealthScore(events);
// 找出问题信号
List<ProblemPattern> problems = identifyProblems(events);
// 找出表现好的场景
List<SuccessPattern> successes = identifySuccesses(events);
return FeatureHealthReport.builder()
.featureId(featureId)
.period(period)
.healthScore(healthScore)
.totalResponses(events.size())
.positiveSignalRate(calculatePositiveRate(events))
.negativeSignalRate(calculateNegativeRate(events))
.problems(problems)
.successes(successes)
.recommendations(generateRecommendations(problems))
.build();
}
/**
* 找出反复出现问题的 query 类型
* 用于指导 prompt 优化
*/
public List<ProblemPattern> identifyProblems(List<SignalEvent> events) {
// 找出负面信号比例高的查询类型
Map<String, List<SignalEvent>> byQueryType = events.stream()
.filter(e -> e.getQueryType() != null)
.collect(Collectors.groupingBy(SignalEvent::getQueryType));
return byQueryType.entrySet().stream()
.filter(entry -> {
double negativeRate = entry.getValue().stream()
.filter(e -> "negative".equals(e.getSentiment()))
.count() * 1.0 / entry.getValue().size();
return negativeRate > 0.3; // 负面率超过 30% 认为是问题
})
.map(entry -> ProblemPattern.builder()
.queryType(entry.getKey())
.negativeRate(calculateNegativeRate(entry.getValue()))
.sampleEvents(entry.getValue().stream().limit(5).collect(Collectors.toList()))
.build())
.sorted(Comparator.comparingDouble(ProblemPattern::getNegativeRate).reversed())
.collect(Collectors.toList());
}
/**
* 把负面信号关联到具体的 AI 回答
* 用于构建改进训练数据
*/
public List<ImprovementCandidate> findImprovementCandidates(String featureId) {
// 找出有负面信号且有用户修改版本的 AI 回答
return eventStore.queryNegativeWithUserRevision(featureId)
.stream()
.map(pair -> ImprovementCandidate.builder()
.originalPrompt(pair.getOriginalPrompt())
.aiResponse(pair.getAiResponse()) // AI 给的内容
.userRevision(pair.getUserRevision()) // 用户改成的版本
.negativeSignals(pair.getNegativeSignals())
.build())
.collect(Collectors.toList());
}
}把信号转化为 Prompt 改进
@Service
public class PromptImprovementService {
/**
* 基于用户信号,生成 Prompt 改进建议
*
* 核心思路:
* 1. 找出有大量负面信号的 AI 回答
* 2. 看这些回答有什么共同特征(太长?不够具体?语气问题?)
* 3. 找出有正面信号的回答,分析它们和负面回答的区别
* 4. 基于分析调整 Prompt
*/
public PromptImprovement analyzeAndSuggestImprovement(String promptId) {
PromptHistory history = promptRepository.findById(promptId).orElseThrow();
// 获取该 Prompt 产生的所有回答及其信号
List<ResponseWithSignals> responses = signalStore.queryByPrompt(promptId, Duration.ofDays(30));
if (responses.size() < 20) {
return PromptImprovement.notEnoughData("需要至少 20 次调用才能分析");
}
// 分析好回答和差回答的特征差异
List<ResponseWithSignals> goodResponses = responses.stream()
.filter(r -> r.getPositiveSignalCount() > r.getNegativeSignalCount())
.collect(Collectors.toList());
List<ResponseWithSignals> badResponses = responses.stream()
.filter(r -> r.getNegativeSignalCount() > r.getPositiveSignalCount() * 2)
.collect(Collectors.toList());
if (badResponses.isEmpty()) {
return PromptImprovement.alreadyGood("当前 Prompt 质量良好");
}
// 统计差回答的特征
double avgBadLength = badResponses.stream()
.mapToInt(r -> r.getContent().length()).average().orElse(0);
double avgGoodLength = goodResponses.stream()
.mapToInt(r -> r.getContent().length()).average().orElse(0);
List<String> suggestions = new ArrayList<>();
if (avgBadLength > avgGoodLength * 1.5) {
suggestions.add("差评回答普遍比好评回答长 50%+,建议 Prompt 中增加对简洁性的要求");
}
// 分析负面信号的具体类型
Map<SignalType, Long> negativeSignalTypes = badResponses.stream()
.flatMap(r -> r.getNegativeSignals().stream())
.collect(Collectors.groupingBy(SignalEvent::getType, Collectors.counting()));
if (negativeSignalTypes.getOrDefault(SignalType.QUICK_ABANDONMENT, 0L) > badResponses.size() * 0.5) {
suggestions.add("大量快速放弃信号,回答可能开头不够吸引人,建议优化回答的开头");
}
if (negativeSignalTypes.getOrDefault(SignalType.REPEAT_QUESTION, 0L) > 3) {
suggestions.add("有重复提问的情况,说明 AI 回答没有真正解决问题,建议检查是否对问题理解有偏差");
}
return PromptImprovement.builder()
.promptId(promptId)
.currentPrompt(history.getCurrentPrompt())
.suggestions(suggestions)
.badResponseSamples(badResponses.stream().limit(3).collect(Collectors.toList()))
.goodResponseSamples(goodResponses.stream().limit(3).collect(Collectors.toList()))
.build();
}
}关于反馈按钮设计的一个小细节
我之前的反馈按钮只有点赞/踩。踩之后显示"感谢反馈",结束。
后来我改成了:踩之后弹出一个简单的选择框:
您觉得这个回答有什么问题?(可多选)
□ 回答不准确
□ 回答太长/太简短
□ 没有真正回答我的问题
□ 语气/风格不对
□ 其他
[提交]这个改动让我们收到了大量有结构的负面反馈,可以直接归因到具体问题。"回答不准确"多了,可能是知识库质量问题;"没有真正回答我的问题"多了,可能是 query 理解问题。
反馈按钮不是为了显示"点赞率"的数字好看,是为了获取可以改进的信息。
用户信号收集体系建好之后,你会发现 AI 改进的方向变得清晰了。不再是凭感觉说"感觉质量不太好",而是数据说话:"知识类问答的快速放弃率是推荐类的 2.3 倍,重点优化知识问答"。
这套体系不是一步到位的,先把显式反馈和最重要的隐式信号做出来,后面逐步完善。
