AI 产品里的数据契约:别等报表和模型一起坏掉
古董级程序员,大厂出来后一直在创业公司,现在仍在一线做 AI 相关的工程。更完整的技术记录写在微信公众号「字与码」:工作经历、对新工具的看法,以及这些年踩过的坑,会不定期发在那里。若这篇对你有用,欢迎顺手关注。
AI 产品里的数据事故,很多时候不会立刻报错。字段还在,类型没变,枚举也没少;报表能跑,训练任务也能跑。真正坏掉的是语义。
这篇用一个工具调用平台的事故来讲数据契约:一个 success 字段原本表示“第三方工具真实执行成功”,后来为了前端体验和运营报表,被改成“平台成功接收并开始处理”。字段名没变,布尔类型没变,但报表成功率虚高,rerank 训练标签被污染,质量告警也被误导。
数据契约不是把表结构抄进文档。它要把字段含义、反例、质量规则、下游用途和发布流程写进工程系统。否则 AI 产品最怕的事情会悄悄发生:模型、报表和运营判断在同一批脏数据上同时变得很自信。

事故是从一张很好看的成功率报表开始的
假设我们有一个工具调用平台。用户输入问题,系统会检索候选工具,rerank 排序,然后调用其中一个工具。工具可能是航班查询、企业信息检索、文档搜索、汇率换算,也可能是内部知识库查询。这里不需要任何真实公司或客户名,把它当成一个通用平台就行。
核心日志表叫 tool_call_events,最初长这样:
| 字段 | 示例 | 最初含义 |
|---|---|---|
event_id | evt_01H… | 单次工具调用事件 ID |
trace_id | tr_9f2… | 一次用户请求链路 |
tool_name | company_lookup | 被调用的工具 |
provider | provider_a | 工具背后的供应方 |
request_valid | true | 平台参数校验是否通过 |
success | true | 第三方工具返回业务成功 |
provider_status | OK | 第三方原始状态 |
error_type | null | 失败归因 |
latency_ms | 842 | 从发起第三方请求到收到响应 |
created_at | 2026-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_success 和 dispatch_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_accepted | owner 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_accepted、provider_success、result_usable |
| T1 | 旧 success 继续写,但标记 deprecated |
| T2 | 报表迁移到 provider_success 或 dispatch_accepted |
| T3 | rerank 标签改用 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=false 或 result_usable=false |
| 排除规则 | 临时供应方故障单独分层,不混入工具适配负例 |
| 覆盖要求 | 每个高频工具每天至少 30 条失败候选 |
| 标签定义 | 工具不适配、参数不足、供应方失败、结果不可用、用户表达歧义 |
| 版本 | eval_tool_failure_v3 |
模型评估的可信度,取决于评估集是否仍在代表真实问题。字段语义漂了,评估集也会漂。评估集一漂,后面的模型对比就像在变动的尺子上量身高。
原始事实不要被派生口径覆盖
这次事故还有一个教训:原始事实要尽量保留,不要被派生口径覆盖。
如果表里只有 success,没有 provider_status、provider_error_code、result_usable、finalized_at,事后就很难恢复。你只能知道某个时间点系统写了 true,却不知道它为什么写 true。对报表来说也许还能人工修正,对训练数据来说就麻烦了,因为你不知道哪些正例是真正的正例。
更稳的做法是保留几层事实:
| 层级 | 字段例子 | 是否可覆盖 |
|---|---|---|
| 原始供应方响应 | provider_status、provider_error_code、响应摘要 hash | 不覆盖,只追加 |
| 平台处理状态 | dispatch_accepted、finalized_at | 可更新状态,但保留历史事件 |
| 质量判定 | result_usable、parse_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_success、dispatch_accepted、result_usable | 把语义拆开 |
| 为训练标签建单独表 | 避免直接消费原始成功字段 |
| 给核心字段写反例 | 让 review 有判断依据 |
| PR 里自动列出下游消费者 | 防止通知漏掉 |
这五件事不依赖昂贵平台,更多是工程纪律。等这些跑起来,再接 lineage、质量看板、契约仓库、自动化发布门禁都不晚。
数据治理最怕一开始就做成大项目。大项目容易写很多抽象规范,却没有进入真实发布流程。一个字段、一张表、一条训练链路先跑通,团队才会相信契约不是形式主义。
复盘这次 success 事故
如果把整个事故收束成一条链路,它大概是这样:
字段名过宽
-> 产品需求把“平台接收成功”写进原 success
-> schema 没变,CI 没拦
-> 报表成功率虚高
-> 告警规则失效
-> rerank 正样本混入失败调用
-> 评估集失败抽样变干净
-> 线上排序变差,排障绕了一大圈
对应的契约修复是:
拆分成功层级
-> 写字段反例和边界情况
-> 登记下游用途
-> 加跨字段质量规则
-> 语义变更进入 PR 门禁
-> 原始事实和训练标签分表
-> 污染窗口回放或标记 exclude
这不是为了多写几份文档,而是为了让一个字段变更在造成模型和报表事故之前,被系统发现。
我现在对数据契约的判断
数据契约不是数据团队的洁癖,也不是大公司才需要的治理流程。只要一个字段同时进入报表、训练、评估、推荐、告警,它就已经是产品接口。接口就应该有语义、版本、owner、反例和发布流程。
AI 产品把数据的影响放大了。普通报表口径错了,会议上可能吵一会儿;训练标签错了,模型会把错误吸收进去;评估集错了,团队还会以为模型变好了;告警错了,事故会更晚被发现。这些问题叠在一起,就不是“数据小瑕疵”,而是产品质量问题。
我不喜欢把数据契约说得很玄。它最朴素的作用,就是让团队在改字段时多问几句具体问题:
这个字段到底表示哪一层成功?哪些情况明确不算?谁在用它训练模型?报表看的是供应方成功还是派发成功?评估集抽样会不会被影响?质量规则能不能发现语义漂移?如果历史数据已经污染,怎么回放或排除?
这些问题问清楚,比事后调一周模型参数更便宜。