AI 产品里的数据契约:别等报表和模型一起坏掉
原创 · 约 48 分钟阅读 · 阅读 --
Last updated on

AI 产品里的数据契约:别等报表和模型一起坏掉

作者: Alex Xiang


古董级程序员,大厂出来后一直在创业公司,现在仍在一线做 AI 相关的工程。更完整的技术记录写在微信公众号「字与码」:工作经历、对新工具的看法,以及这些年踩过的坑,会不定期发在那里。若这篇对你有用,欢迎顺手关注。

AI 产品里的数据事故,很多时候不会立刻报错。字段还在,类型没变,枚举也没少;报表能跑,训练任务也能跑。真正坏掉的是语义。

这篇用一个工具调用平台的事故来讲数据契约:一个 success 字段原本表示“第三方工具真实执行成功”,后来为了前端体验和运营报表,被改成“平台成功接收并开始处理”。字段名没变,布尔类型没变,但报表成功率虚高,rerank 训练标签被污染,质量告警也被误导。

数据契约不是把表结构抄进文档。它要把字段含义、反例、质量规则、下游用途和发布流程写进工程系统。否则 AI 产品最怕的事情会悄悄发生:模型、报表和运营判断在同一批脏数据上同时变得很自信。

AI 产品数据契约生命周期图

事故是从一张很好看的成功率报表开始的

假设我们有一个工具调用平台。用户输入问题,系统会检索候选工具,rerank 排序,然后调用其中一个工具。工具可能是航班查询、企业信息检索、文档搜索、汇率换算,也可能是内部知识库查询。这里不需要任何真实公司或客户名,把它当成一个通用平台就行。

核心日志表叫 tool_call_events,最初长这样:

字段示例最初含义
event_idevt_01H…单次工具调用事件 ID
trace_idtr_9f2…一次用户请求链路
tool_namecompany_lookup被调用的工具
providerprovider_a工具背后的供应方
request_validtrue平台参数校验是否通过
successtrue第三方工具返回业务成功
provider_statusOK第三方原始状态
error_typenull失败归因
latency_ms842从发起第三方请求到收到响应
created_at2026-06-15T09:12:03Z事件时间

最初 success=true 的意思很窄:平台真的向第三方发起了请求,第三方返回了业务成功,结果可被用户使用。参数校验通过但第三方超时,不算成功;命中缓存但缓存已过期,不算成功;平台只把任务放进队列,也不算成功。

后来产品改了一版交互。为了让用户少看到红色失败提示,平台把工具调用拆成了两个阶段:前端先展示“已开始处理”,后端异步等待第三方结果。与此同时,运营看板想看“请求是否被平台成功接收”。工程师觉得原来的 success 字段正好能用,就把写入逻辑改了:

旧逻辑:success = provider_status == "OK" && result_usable == true
新逻辑:success = request_valid == true && dispatch_state in ("queued", "sent", "completed")

这次变更没有改表结构。success 仍然是 boolean。枚举没有变化。旧 SQL 没有报错。看板甚至更好看了:成功率从 82% 涨到 96%。问题是,涨的不是工具真实成功率,而是“平台接收成功率”。

坏掉的不止一张报表

如果这个字段只服务一个运营看板,事故还算容易发现。麻烦在于 AI 产品的日志常常有很多隐形下游。

这个平台里,success 至少被四类系统使用:

下游用途被污染后的后果
运营报表展示工具调用成功率、供应方健康度成功率虚高,供应方故障被掩盖
告警规则连续低成功率时报警因为 success 变宽,报警延迟或不触发
rerank 训练把成功调用当作正样本第三方失败、空结果、超时被混入正例
评估集抽样从失败调用中抽边界样本失败样本减少,评估集变得过于干净

rerank 污染尤其隐蔽。模型训练不会因为标签变脏而报错,它只会慢慢学错。原本某个工具在“企业工商信息查询”上经常超时,应该成为负例;现在这些请求因为参数合法、任务已派发,被标成 success=true。模型会学到一个错误信号:这个工具在这些 query 上表现不错。

几周后,线上排序变差。用户看到的不是“系统错误”,而是工具选择越来越奇怪:明明文档搜索能回答的问题,模型偏要调用一个经常返回空结果的外部工具;明明某个供应方最近不稳定,rerank 仍然把它排在前面。会议上大家会先怀疑模型、提示词、训练参数、召回策略,很少第一时间怀疑一个 boolean 字段的语义变了。

这就是 AI 数据事故难排的地方。字段没有消失,流水线没有失败,所有系统都在正常运行,只是一起运行在错误口径上。

事后看,字段名本身就埋了雷

success 是一个很危险的字段名。它太像一句结论,却没有说明成功的是谁、在哪一层成功、对谁有用。

在工具调用平台里,至少有五种“成功”:

成功层级字段名建议含义
参数成功request_valid平台校验参数格式和必填项通过
派发成功dispatch_accepted任务进入执行队列或已发给供应方
供应方成功provider_success第三方返回业务成功状态
结果可用result_usable返回内容非空、格式可解析、满足基本质量规则
用户成功user_resolved用户采纳、没有追问或明确反馈有用

把这五种东西都叫 success,短期省事,长期一定出事。报表同学看到 success,会理解成调用成功;训练同学看到 success,会理解成正样本;产品同学看到 success,可能理解成用户问题解决。大家都没恶意,只是同一个词在不同语境里太容易被重载。

数据契约要做的第一件事,就是把这种歧义压下去。字段可以短,但语义不能短。

一份契约应该写到反例

如果只写“success: 是否成功”,这份契约等于没写。真正有用的是把正例、反例和边界情况写出来。

我会把原始事件的契约拆成这样:

dataset: tool_call_events
version: 2.1.0
owner: tool-platform-data
grain: one row per attempted provider call
time_field: created_at
freshness_sla: 15 minutes

fields:
  provider_success:
    type: boolean
    nullable: false
    meaning: >
      True only when the provider returns a business-level successful response
      and the platform receives a parseable payload for this call.
    positive_examples:
      - provider_status is OK and response body contains at least one usable result
      - provider_status is OK and empty result is a valid business answer for this tool
    negative_examples:
      - request validation passed but provider request was not sent
      - provider timed out
      - provider returned HTTP 200 with business error code
      - provider returned malformed payload
      - platform queued an async job but final provider result is unknown
    allowed_transitions:
      - from: null
        to: true
        when: final provider response is observed
      - from: null
        to: false
        when: provider failure or unusable result is observed

  dispatch_accepted:
    type: boolean
    nullable: false
    meaning: True when the platform accepted the request and created an execution attempt.

  result_usable:
    type: boolean
    nullable: false
    meaning: True when returned content passes parser and minimum result quality checks.

  label_for_rerank:
    type: enum
    values: [positive, negative, exclude]
    meaning: Stable training label derived from provider_success, result_usable, user feedback, and sampling rules.

注意这里没有继续使用裸 success。如果历史兼容必须保留,也应该把它降级成兼容字段:

  success:
    type: boolean
    deprecated: true
    meaning: Legacy field. Do not use for new metrics, labels, alerts, or exports.
    replacement:
      metrics: provider_success
      dispatch_dashboard: dispatch_accepted
      rerank_training: label_for_rerank

反例很重要。很多字段事故不是因为没人知道正确定义,而是边界情况没人写。工程师写代码时遇到“HTTP 200 但 body 里是错误码”,如果契约没说,他就会按自己的理解处理。契约里把反例写明白,代码 review 才有依据。

表结构也要支持语义分层

契约不是只写 YAML。表本身也要给语义留位置。上面的事故里,如果所有成功概念都挤在一个 success 字段里,下游很难不误用。

我更倾向把原始日志表改成这样:

create table tool_call_events (
  event_id text primary key,
  trace_id text not null,
  user_request_id text not null,
  tool_name text not null,
  provider text not null,
  request_valid boolean not null,
  dispatch_accepted boolean not null,
  provider_success boolean,
  result_usable boolean,
  provider_status text,
  provider_error_code text,
  error_type text,
  latency_ms integer,
  created_at timestamp not null,
  finalized_at timestamp,
  contract_version text not null
);

然后用一张训练标签表承接模型用途:

create table rerank_training_labels (
  label_id text primary key,
  event_id text not null,
  trace_id text not null,
  query_hash text not null,
  tool_name text not null,
  label text not null check (label in ('positive', 'negative', 'exclude')),
  label_reason text not null,
  source_contract_version text not null,
  generated_at timestamp not null
);

这样做有两个好处。

第一,原始事实和派生标签分开。provider_success=false 不一定直接等于训练负例。比如用户参数明显写错,可能应该 exclude,否则模型会被训练成“不要选这个工具”,但真实问题是用户输入不完整。又比如供应方短暂故障导致失败,这可以作为供应方健康度负例,却未必适合作为长期 rerank 负例。

第二,标签生成有版本。模型训练时可以明确自己用的是哪一版契约和哪一版标签规则。以后发现规则有问题,可以回放重新生成,而不是在一张表里猜历史字段当时到底是什么意思。

质量规则不能只查非空

很多数据质量检查停留在 schema 层:字段是否存在、类型是否正确、是否为空。它们有用,但抓不到这次事故。success 的类型一直是 boolean,非空率也很好。

这类语义漂移要靠跨字段、跨表、跨时间的规则。

我会给工具调用平台加这些检查:

quality_rules:
  - name: provider_success_requires_final_state
    severity: blocking
    expression: provider_success is null or finalized_at is not null
    description: provider_success cannot be finalized before provider result is observed

  - name: provider_success_not_equal_dispatch
    severity: warning
    expression: corr(dispatch_accepted, provider_success) < 0.98 over 7 days
    description: provider_success suspiciously tracks dispatch_accepted too closely

  - name: success_rate_matches_error_distribution
    severity: warning
    expression: >
      if provider_success_rate increases by more than 10pp day over day,
      provider_error_rate must decrease or result_usable_rate must increase
    description: success rate jump without error/result movement is suspicious

  - name: rerank_label_positive_requires_usable_result
    severity: blocking
    expression: label != 'positive' or result_usable = true
    description: positive training labels require usable results

  - name: timeout_cannot_be_provider_success
    severity: blocking
    expression: error_type != 'timeout' or provider_success = false
    description: timeout is never provider success

第二条看起来有点怪,但很实用。如果 provider_successdispatch_accepted 在七天窗口里几乎完全一致,说明有人可能把供应方成功写成了派发成功。语义规则不一定都能百分百证明错误,但它们能把“值得人看一眼”的异常推到台前。

对训练标签还要做分布检查:

规则目的
每个工具的正负例比例不能单日剧烈变化防止标签生成规则或埋点突变
label=positive 必须能追溯到可用结果或用户采纳防止把平台成功当用户成功
供应方故障窗口内的样本默认 exclude 或单独标记防止临时故障污染长期排序偏好
采样后的 query 类型分布要和线上曝光接近防止训练集越来越偏

AI 产品的数据质量,不只是“数据有没有来”。更重要的是“这个数据还能不能代表我们以为的那个事实”。

下游用途要登记,不然通知一定漏

事故发生时,上游经常会说:“我不知道这个字段还被训练用。”这句话很常见,也很真实。靠人记住所有下游,是不可靠的。

契约里应该登记字段级用途:

consumers:
  - name: ops_provider_health_dashboard
    type: dashboard
    fields:
      - provider_success
      - error_type
      - latency_ms
    owner: analytics
    criticality: high

  - name: rerank_daily_training_set
    type: training_pipeline
    fields:
      - provider_success
      - result_usable
      - error_type
      - tool_name
    owner: search-ml
    criticality: high

  - name: failure_case_eval_sampler
    type: evaluation_dataset
    fields:
      - provider_success
      - error_type
      - provider_status
    owner: eval-platform
    criticality: medium

这样字段变更时,系统至少能自动告诉你影响谁。没有这一步,通知会退化成群里喊一句“这个字段我改一下”,然后一定有人没看到。

更重要的是,下游也要承担责任。训练团队不能只在 SQL 里悄悄用一个字段,然后指望上游永远不改。只要一个字段被用作核心指标、训练标签、评估抽样、结算依据,就应该登记用途和 owner。数据契约不是上游单方面服务下游,而是双方把依赖关系显性化。

发布流程要区分新增、重命名和改语义

很多团队害怕数据契约,是因为一听就觉得流程重。其实流程可以很轻,但风险分级要清楚。

对这个平台,我会把变更分成四档:

变更类型例子流程
兼容新增新增 dispatch_acceptedowner review,CI 检查契约格式
字段重命名success 拆成 provider_success下游通知,双写一段时间
语义收窄/放宽provider_success 是否允许空结果算成功下游 owner 审批,回放历史影响
删除或停止维护移除旧 success发布窗口,迁移检查,回滚方案

那次事故的问题不在于没人写代码 review,而是 review 没把“语义变更”当成高风险变更。PR 里如果只看到一行:

- success = provider_status == "OK"
+ success = request_valid && dispatch_state != "rejected"

很容易被当成产品口径调整。契约流程应该让这类 diff 自动冒出来:

Contract impact:
- Field `success` is used by 3 critical consumers.
- Semantic source changes from provider result to dispatch state.
- This is a breaking semantic change.
- Required action: create new field `dispatch_accepted`, keep `provider_success`, mark `success` deprecated.

不需要每个字段都开大会,但影响核心指标和训练标签的字段,必须有人认真看。AI 产品里,语义变更就是接口变更。

兼容迁移不能只靠改名

发现 success 被重载之后,最直接的修复是新增字段并双写:

阶段动作
T0新增 dispatch_acceptedprovider_successresult_usable
T1success 继续写,但标记 deprecated
T2报表迁移到 provider_successdispatch_accepted
T3rerank 标签改用 label_for_rerank 生成表
T4回放历史数据,重建污染窗口内的训练标签
T5契约拒绝新增下游继续使用 success

其中最容易被忽略的是 T4。只改未来数据,不处理历史污染,模型训练仍然可能吃到旧脏标签。要不要回放,取决于污染字段是否进入训练集、评估集或核心报表。

回放时也要谨慎。并不是所有历史事件都能恢复真实 provider_success。如果当时没有保存 provider_status、原始错误码和结果摘要,就只能标记为 unknown 或 exclude。不要为了让数据“完整”,用现在的猜测补一个确定标签。宁可少一点训练样本,也不要把不确定样本伪装成正例。

一张最小可用的契约表

如果还没有数据治理平台,不必等平台建好才开始。用几张表也能跑起来。

字段契约表可以长这样:

create table data_field_contracts (
  dataset_name text not null,
  field_name text not null,
  contract_version text not null,
  data_type text not null,
  nullable boolean not null,
  semantic_owner text not null,
  meaning text not null,
  positive_examples text not null,
  negative_examples text not null,
  allowed_values text,
  freshness_sla_minutes integer,
  deprecated boolean not null default false,
  replacement_field text,
  updated_at timestamp not null,
  primary key (dataset_name, field_name, contract_version)
);

下游依赖表:

create table data_contract_consumers (
  dataset_name text not null,
  field_name text not null,
  consumer_name text not null,
  consumer_type text not null,
  owner text not null,
  criticality text not null,
  usage_note text not null,
  created_at timestamp not null,
  primary key (dataset_name, field_name, consumer_name)
);

质量规则表:

create table data_quality_rules (
  rule_name text primary key,
  dataset_name text not null,
  severity text not null,
  rule_sql text not null,
  owner text not null,
  run_frequency text not null,
  enabled boolean not null default true
);

这些表不高级,但足够把契约从 Wiki 拉进工程流程。PR 改字段时查一下依赖表,调度后跑一下质量规则,报警时带上 owner 和契约版本。做到这些,已经比“字段说明写在某个文档里”强很多。

SQL 也要写得像在使用契约

契约不是写完给别人看的。下游 SQL 要体现契约意识。

污染前,报表可能这样写:

select
  date(created_at) as dt,
  provider,
  avg(case when success then 1 else 0 end) as success_rate
from tool_call_events
group by 1, 2;

修复后应该明确成功层级:

select
  date(created_at) as dt,
  provider,
  avg(case when provider_success then 1 else 0 end) as provider_success_rate,
  avg(case when dispatch_accepted then 1 else 0 end) as dispatch_accept_rate,
  avg(case when result_usable then 1 else 0 end) as usable_result_rate
from tool_call_events
where contract_version >= '2.1.0'
group by 1, 2;

训练标签也不要直接拿 provider_success 当 label:

insert into rerank_training_labels
select
  generate_label_id(event_id) as label_id,
  event_id,
  trace_id,
  query_hash,
  tool_name,
  case
    when result_usable = true and user_feedback in ('accepted', 'copied', 'no_followup') then 'positive'
    when error_type in ('tool_not_applicable', 'empty_unhelpful_result') then 'negative'
    when error_type in ('provider_timeout', 'rate_limited', 'temporary_provider_error') then 'exclude'
    else 'exclude'
  end as label,
  build_label_reason(provider_success, result_usable, error_type, user_feedback) as label_reason,
  contract_version as source_contract_version,
  current_timestamp as generated_at
from tool_call_events
where created_at >= current_date - interval '7 days';

这段 SQL 的重点不是函数名,而是态度:训练标签是一个独立的数据产品,有自己的规则和反例。它可以使用原始字段,但不能把原始字段的某个“成功”直接等同于模型要学的“好样本”。

评估集也会被 success 污染

很多团队会盯训练数据,忽略评估集。其实评估集更容易被这种字段污染影响结论。

假设评估集抽样规则是:

每天从 success=false 的工具调用中抽 200 条,人工标注失败原因。

success 被改成派发成功后,失败池突然变小。真正的第三方失败、空结果、超时请求不再进入抽样。评估集看起来越来越干净,模型评估分数也可能变好。但这不是系统变好了,而是难题被抽样规则过滤掉了。

评估集契约也要写明:

示例
样本来源tool_call_events 中 provider_finalized 的事件
失败定义provider_success=falseresult_usable=false
排除规则临时供应方故障单独分层,不混入工具适配负例
覆盖要求每个高频工具每天至少 30 条失败候选
标签定义工具不适配、参数不足、供应方失败、结果不可用、用户表达歧义
版本eval_tool_failure_v3

模型评估的可信度,取决于评估集是否仍在代表真实问题。字段语义漂了,评估集也会漂。评估集一漂,后面的模型对比就像在变动的尺子上量身高。

原始事实不要被派生口径覆盖

这次事故还有一个教训:原始事实要尽量保留,不要被派生口径覆盖。

如果表里只有 success,没有 provider_statusprovider_error_coderesult_usablefinalized_at,事后就很难恢复。你只能知道某个时间点系统写了 true,却不知道它为什么写 true。对报表来说也许还能人工修正,对训练数据来说就麻烦了,因为你不知道哪些正例是真正的正例。

更稳的做法是保留几层事实:

层级字段例子是否可覆盖
原始供应方响应provider_statusprovider_error_code、响应摘要 hash不覆盖,只追加
平台处理状态dispatch_acceptedfinalized_at可更新状态,但保留历史事件
质量判定result_usableparse_error_type可重算,记录规则版本
业务指标provider_success_rate派生,不写回原始事实
训练标签label_for_rerank派生,记录版本和原因

隐私和合规当然要考虑。原始响应不一定能长期保存全文,可以保存脱敏摘要、错误码、结构化元数据、短期隔离存储。但不要把唯一的事实源改没。没有事实源,契约也只能写愿望。

契约失败后的系统行为

数据质量规则报警以后怎么办?只发消息不处理,模型可能继续训练;直接阻断所有任务,又可能影响业务。契约应该定义失败行为。

对这个平台,我会这样处理:

失败场景行为
provider_success 写入违反 blocking 规则阻断发布或回滚写入逻辑
训练标签正例中出现不可用结果停止当天训练任务
报表新鲜度超过 SLA看板展示延迟提示,不用于周报结论
供应方故障导致负例激增标签生成将该窗口样本标为 exclude
契约版本不匹配下游任务失败并提示迁移说明

这一步很关键。很多数据平台能发现问题,但发现之后没有系统行为,最后还是靠人半夜看报警。AI 产品的训练、评估、索引、报表都有自动化流水线,契约失败也应该进入自动化控制面。

组织上要有人对语义负责

字段语义不是纯技术问题。provider_success 到底是否允许“空结果但业务上合法”的情况算成功,需要产品、工程、数据、模型一起判断。比如企业查询工具里,“没有查到企业”可能是一个合法结果;文档搜索里返回空列表,可能就是没帮上用户。

所以 owner 不能只写“数据团队”。我会至少拆三种 owner:

owner负责什么
Producer owner事件在哪里产生,写入逻辑怎么保证
Semantic owner字段业务含义、反例、口径变更
Consumer owner下游怎么使用,迁移和验证

一个字段语义变更,Producer owner 可以评估实现,Semantic owner 判断口径,Consumer owner 评估影响。没有这个分工,事故后大家都会说自己只是按需求改了一行代码。

小团队也能开始

如果团队很小,不需要一开始上完整的数据契约平台。可以从这个事故里最痛的点开始。

我会先做五件事:

动作目的
禁止新代码使用裸 success先止血
新增 provider_successdispatch_acceptedresult_usable把语义拆开
为训练标签建单独表避免直接消费原始成功字段
给核心字段写反例让 review 有判断依据
PR 里自动列出下游消费者防止通知漏掉

这五件事不依赖昂贵平台,更多是工程纪律。等这些跑起来,再接 lineage、质量看板、契约仓库、自动化发布门禁都不晚。

数据治理最怕一开始就做成大项目。大项目容易写很多抽象规范,却没有进入真实发布流程。一个字段、一张表、一条训练链路先跑通,团队才会相信契约不是形式主义。

复盘这次 success 事故

如果把整个事故收束成一条链路,它大概是这样:

字段名过宽
  -> 产品需求把“平台接收成功”写进原 success
  -> schema 没变,CI 没拦
  -> 报表成功率虚高
  -> 告警规则失效
  -> rerank 正样本混入失败调用
  -> 评估集失败抽样变干净
  -> 线上排序变差,排障绕了一大圈

对应的契约修复是:

拆分成功层级
  -> 写字段反例和边界情况
  -> 登记下游用途
  -> 加跨字段质量规则
  -> 语义变更进入 PR 门禁
  -> 原始事实和训练标签分表
  -> 污染窗口回放或标记 exclude

这不是为了多写几份文档,而是为了让一个字段变更在造成模型和报表事故之前,被系统发现。

我现在对数据契约的判断

数据契约不是数据团队的洁癖,也不是大公司才需要的治理流程。只要一个字段同时进入报表、训练、评估、推荐、告警,它就已经是产品接口。接口就应该有语义、版本、owner、反例和发布流程。

AI 产品把数据的影响放大了。普通报表口径错了,会议上可能吵一会儿;训练标签错了,模型会把错误吸收进去;评估集错了,团队还会以为模型变好了;告警错了,事故会更晚被发现。这些问题叠在一起,就不是“数据小瑕疵”,而是产品质量问题。

我不喜欢把数据契约说得很玄。它最朴素的作用,就是让团队在改字段时多问几句具体问题:

这个字段到底表示哪一层成功?哪些情况明确不算?谁在用它训练模型?报表看的是供应方成功还是派发成功?评估集抽样会不会被影响?质量规则能不能发现语义漂移?如果历史数据已经污染,怎么回放或排除?

这些问题问清楚,比事后调一周模型参数更便宜。

参考资料