AI 预测可视化平台 | 从前端到模型反馈闭环:标注数据回传后端设计
# 引言 我们建立了异常标记、偏差归因、SHAP 解释、场景模拟、报告批注——所有这些交互都产生了一类被传统 BI 完全浪费掉的数据:**人在这个界面上做了什么判断**。计划员确认了一个异常、驳回了一个误报、在图上批注"双十一实际超预期",这些动作本身就是高质量的训练样本。 模型反馈闭环要做的,就是把散落的标注收拢成一条**可信、可追溯、可直接进训练集**的数据管道。前端在这个闭环里的职责不是"把数据发出去"这么简单,而是**保证标注的合法性、减少回传量、处理离线场景、并把模型改进后的结果重新呈现给用户**。本文将拆解数据模型、状态机、批量提交、幂等与重试、前后端契约、冲突处理,以及闭环的最终验证机制。 --- # 一、先厘清:反馈闭环到底闭环在哪 很多团队理解的"反馈"只是把标注存进数据库,然后就没了。这不是闭环,这是垃圾场。  **解析**:真正的闭环有七个环节,且**必须回到用户眼前**。用户提交了标注却看不到任何变化,第二次就不会再提交——**闭环的最后一环是"让用户看见模型因为他而变好了"**,这是标注系统可持续运转的唯一动力。 --- # 二、数据模型:标注不是一条备注,是一条样本 标注要能进训练集,就必须携带足够的上下文。缺了任何一个字段,这条数据就只能是审计日志,成不了样本。 ```javascript { "feedbackId": "fb_88201", "sku": "SKU-1002331", "targetDate": "2026-11-11", "modelVersion": "prophet-v1.4.2", "modelOutput": { "yhat": 1370, "yhatLower": 1180, "yhatUpper": 1470, "actual": 1520 }, "label": { "verdict": "confirmed", "rootCause": "promo_underestimated", "severity": 3, "comment": "渠道促销未纳入特征,实际超预期", "tags": ["promotion", "holiday"] }, "context": { "price": 89, "promoIntensity": 0.9, "inventory": 3200, "isHoliday": true }, "author": "u_1024", "role": "demand_planner", "createdAt": "2026-09-25T10:31:00+08:00", "status": "submitted", "quality": { "hasActual": true, "contextComplete": true, "authorReliable": true } } ``` **解析**:`modelOutput` 把提交时刻的模型输出冻结下来——**这是关键,因为模型会重训,当时的预测值会变**。`context` 把当时的特征快照一起存下,让这条数据可以直接作为监督样本。`quality` 三个布尔值是质检的自动判定,决定它能否进入训练集。`author` 和 `role` 记录来源,后续可做标注者可靠性加权。 `rootCause` 必须取自受控枚举,不能让用户自由输入: ```javascript const ROOT_CAUSES = { promo_underestimated: '促销强度低估', price_effect_missed: '价格效应未捕捉', holiday_shift: '节假日效应偏移', supply_shortage: '供应不足导致销量被压低', data_quality: '源数据质量问题', model_baseline_drift: '基线漂移', known_event: '已知事件,非模型问题', } export function validateLabel(label) { const errors = [] if (!ROOT_CAUSES[label.rootCause]) { errors.push('根因必须从标准分类中选择') } if (!label.comment?.trim()) { errors.push('说明不能为空,需描述判断依据') } if (label.severity 3) { errors.push('严重度取值必须为 1~3') } return errors } ``` **解析**:枚举化的根因是训练数据可用性的前提。**自由文本进不了结构化训练集,只能靠 NLP 清洗,成本极高且噪声大**。说明必填则保证每条标注都有判断依据,可供复核。这套校验要在**前端提交前和后端入库前各跑一遍**,双保险。 --- # 三、状态机:一条标注的完整生命周期 标注不是提交即完成,它要经过质检、采纳、训练多个状态。状态机必须显式建模。  **解析**:`drafting` 和 `pending` 的区别在于是否进入发送队列。`failed` 不丢数据,而是留在本地队列等待重试。`rejected`** 必须可退回修改**,否则标注者遇到一次拒绝就不再提交。`training` 状态就是"已闭环"的标志,前端据此给用户正向反馈。 ```javascript const TRANSITIONS = { drafting: ['pending'], pending: ['submitted', 'failed'], failed: ['pending'], submitted: ['validating'], validating:['accepted', 'rejected'], rejected: ['drafting'], accepted: ['training', 'archived'], training: ['*'], // 终态 archived: ['*'], } export function canTransition(from, to) { const allowed = TRANSITIONS[from] || [] return allowed.includes(to) || allowed.includes('*') } ``` **解析**:`TRANSITIONS` 表驱动所有状态变更,**禁止任何代码绕过这张表直接改状态**。终态 `training` 和 `archived` 用 `'*'` 表示不再允许变更,是防止重复训练的锁。 --- # 四、本地队列:离线也要能标注 供应链场景里,计划员常在仓库、门店等弱网环境工作。标注动作必须能离线发生。 ```javascript const QUEUE_KEY = 'feedback-queue' class FeedbackQueue { constructor() { this.items = this._load() } _load() { try { return JSON.parse(localStorage.getItem(QUEUE_KEY) || '[]') } catch { return [] } } _persist() { localStorage.setItem(QUEUE_KEY, JSON.stringify(this.items)) } enqueue(item) { const normalized = { ...item, feedbackId: item.feedbackId || `fb_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, status: 'pending', attempts: 0, createdAt: new Date().toISOString(), } this.items.push(normalized) this._persist() return normalized } async flush(sender) { const pending = this.items.filter(i => i.status === 'pending') for (const item of pending) { try { item.attempts += 1 await sender(item) item.status = 'submitted' } catch (err) { item.status = item.attempts >= 5 ? 'failed' : 'pending' item.lastError = err.message if (item.status === 'failed') break // 停止重试,等待人工或下次重连 } } this._persist() return this.items.filter(i => i.status === 'submitted').length } } ``` **解析**:队列持久化到 localStorage,**刷新和断网都不丢**。`feedbackId` 在客户端生成并用时间戳+随机串,保证离线时也能唯一标识。`attempts` 计数配合最多 5 次重试,**连续失败就停止而非无限重试耗尽电池和网络**。每轮 flush 后统一持久化,保证任意时刻中断都可恢复。 --- # 五、批量提交与幂等 逐条发请求会让 50 条标注产生 50 次 HTTP 往返。批量提交 + 幂等键是这里的标配。  **解析**:批量提交一次往返处理多条,是弱网环境的必需品。幂等键让重复提交安全——网络超时后前端无法判断服务端是否收到,只能重试,此时幂等保证不会插入重复数据。 ```javascript export async function sendBatch(items, { signal, maxBatch = 50 } = {}) { const results = [] for (let i = 0; i c.feedbackId))) const res = await fetch('/api/feedback/batch', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': `batch-${key}`, }, body: JSON.stringify({ items: chunk }), signal, }) if (res.status === 409) { // 幂等命中:视为成功,无需重复插入 results.push(...chunk.map(c => ({ feedbackId: c.feedbackId, status: 'submitted' }))) continue } const json = await res.json() results.push(...json.results) } return results } ``` **解析**:`maxBatch` 控制单批大小,**避免一次 POST 体积过大触发网关限制**。`Idempotency-Key` 用批次内所有 feedbackId 的哈希生成,保证"同一批内容"必然命中同一键。**409 不算错误而是幂等命中**,这条判断不能漏。 --- # 六、前后端契约:富化与质检的分工 前端不该算质检结论,后端也不该重新推断用户已经确认的信息。分工要明确。  **解析**:前端负责"保证数据完整",后端负责"保证数据可信"。**上下文特征必须在前端随提交一起抓取**,因为模型重训后原始特征可能已不可得。`quality` 里的 `authorReliable` 由后端根据历史标注与复核结果动态计算,前端不碰。 ```javascript // 前端提交前的富化:补齐模型快照与上下文 export function enrichFeedback(draft, { series, modelVersion, context }) { const point = series.find(d => d.ds === draft.targetDate) return { feedbackId: draft.feedbackId, sku: draft.sku, targetDate: draft.targetDate, modelVersion, modelOutput: { yhat: point?.yhat, yhatLower: point?.yhatLower, yhatUpper: point?.yhatUpper, actual: point?.actual ?? null, }, label: { verdict: draft.verdict, rootCause: draft.rootCause, severity: draft.severity, comment: draft.comment.trim(), tags: draft.tags || [], }, context, author: draft.author, role: draft.role, } } ``` **解析**:`actual` 可能为 null(预测期),用 `?? null` 显式表达"暂无实际值",而不是省略字段——**省略会让后端无法区分"未采集"和"零值"**。`context` 由调用方注入,保证提交时刻的特征是最新的。 --- # 七、冲突与复核:标注之间也会打架 同一天可能被两人标注为不同根因,也可能与模型结论冲突。这些不是要消除,而是要记录下来供仲裁。  **解析**:分歧不是噪声。**两个人给出不同根因,说明分类体系有歧义或特征确实有复合原因**,这比一致标注更有信息量,应保留并触发复核。 ```javascript export function detectConflicts(feedbacks) { const groups = new Map() feedbacks.forEach(f => { const k = `${f.sku}:${f.targetDate}` if (!groups.has(k)) groups.set(k, []) groups.get(k).push(f) }) const conflicts = [] for (const [k, items] of groups) { const causes = new Set(items.map(i => i.label.rootCause)) if (causes.size > 1) { conflicts.push({ key: k, items, reason: 'root_cause_diverged' }) } } return conflicts } ``` **解析**:按 SKU+日期分组,`Set` 的 size 大于 1 即存在分歧。**分歧记录本身要入库并关联原始标注**,供仲裁时查看各方理由。前端在列表页用醒目标记提示"该点存在分歧,待复核"。 --- # 八、闭环验证:让用户看见改善 这是全篇最容易被漏掉、却最决定标注系统生死的一环。  **解析**:曲线持续上升,是"你的标注在起作用"的最直接证明。**这条曲线必须出现在前端首页**,而不是藏在后台报表里。没有它,标注系统撑不过三个月。 ```javascript export function FeedbackImpact({ stats }) { const items = stats.versions.map(v => ({ version: v.version, adoption: v.acceptedFeedback / v.totalFeedback * 100, wmape: v.wmape, })) return ( 你的标注带来的变化 {items.map(it => ( {it.version} 采纳 {it.adoption.toFixed(0)}% ))} 累计 {stats.totalContributed} 条标注中,{stats.totalAccepted} 条已进入训练集, 相关 SKU 平均 WMAPE 改善 {(stats.wmapeDelta * 100).toFixed(1)} 个百分点 ) } ``` **解析**:**"你的标注带来的变化"是这句话的精准表述**,不是"系统贡献"。末尾的量化结论把个人动作与业务结果直接挂钩,是最强的行为激励。百分比条用绿色,强化正向反馈。 --- # 九、可观测性与合规 反馈管道一旦失效,模型迭代就会静默停滞。必须有端到端的可观测性。  **解析**:队列长度反映离线积压,提交成功率反映管道健康,质检时长反映后端处理能力。**三个指标任一异常都该告警**,因为标注丢失是不可逆的(用户不会记得自己标过什么)。 ```javascript export function telemetry(queue) { const pending = queue.items.filter(i => i.status === 'pending').length const failed = queue.items.filter(i => i.status === 'failed').length const total = queue.items.length // 上报到监控(如 Sentry / 自建埋点) report('feedback_queue', { total, pending, failed, oldestPending: queue.items.find(i => i.status === 'pending')?.createdAt, }) if (failed > 10) { notify('标注回传多次失败,已暂停重试,请检查网络') } } ``` **解析**:`oldestPending` 暴露积压时长,比单纯计数更有告警价值。**失败超过阈值就停止重试并提示用户**,避免无意义的网络消耗。 合规上,标注数据含业务判断,需明确权属与留存: ```javascript export function applyRetention(feedbacks, policy) { const cutoff = new Date() cutoff.setMonth(cutoff.getMonth() - policy.months) return feedbacks.filter(f => { const created = new Date(f.createdAt) if (created < cutoff && f.status === 'archived') return false return true }) } ``` **解析**:只有已归档且超期的记录才清理,accepted/training 状态的数据即便超期也保留——**训练数据一旦删除,模型就失去可复现性**。留存期限从策略读取,便于按合规要求调整。 --- # 结语 反馈闭环是预测平台从"给人看的仪表盘"进化为"能自我改进的智能系统"的分界点。本文给出了一条完整链路:**先把标注建模成带模型快照与特征上下文的结构化样本,再用显式状态机管理从草稿到训练的全生命周期,用本地队列保证弱网不丢数据,用批量提交与幂等键保证高效且安全的回传,用冲突检测保留分歧信号,最后把"采纳率提升、误差改善"直接呈现给标注者,让闭环真正闭环。** 阅读本文的收获: - **标注必须冻结当时的模型输出与特征上下文,否则重训后失去训练价值;** - **状态机要显式建模且用表驱动,禁止绕行;** - **离线场景必须用持久化队列与有限重试,失败超阈值即停止而非耗尽资源;** - **分歧不是噪声而是分类体系待改进的信号,应保留并触发复核;** - **闭环的最后一环是让用户看见自己的贡献带来的改善,这是标注系统能否持续运转的唯一动力。**