《互联网产品的 AI 架构·多能力编排与任务闭环》5.2 部分可用的验收标准

上游只交回半份结果时,什么算可用、什么必须拒绝?本节为「回声」三条链定义字段契约:哪些字段必须有、哪些可缺、哪些允许降级替代,给出完整、部分可用、拒绝交付三档判定、四级用户提示与机器可读标记,并附可运行的 TypeScript 验收器与示例运行结果。

半份结果算不算结果

5.1 把失败分成了四类。可跳过与可降级这两类不会让链路停下,它们会让链路带着缺口继续。于是编排器必须回答一个比错误码更棘手的问题:缺口出现之后,这份结果还能不能交给用户?

「回声」的内容发布链给出过一个真实形状的难题。摘要节点失败两次后,模板生成了一个 200 字摘要;审核分类正常返回了风险等级;要点字段因为上游超时缺失。三个字段里,一个降级、一个正常、一个缺失。这条结果算成功、算部分成功,还是算失败?

如果编排器只返回一个 ok 布尔值,下游只能猜。客服链会把缺少引用出处的答复直接发给用户;下单咨询链会把没有排序结果的推荐说明当成完整答案。用户看到的是一段流畅的文字,不知道其中一部分是模板,另一部分根本不存在。

所以「部分可用」不能是一个形容词,必须是一组可判定的字段规则:哪些字段必须有,哪些可以缺,哪些允许用替代产物,缺了之后用户看到什么、系统如何标注。

本节的所有验收结果都来自 ai-lab 的示例样本运行,五组构造输入用于覆盖完整、部分、拒绝三条分支,不代表线上分布。

契约先于失败处理

字段契约应该在编排图设计时写死,而不是等失败发生后再临时决定。原因很简单:失败发生时,编排器没有足够信息判断「这个字段重不重要」,它只能查契约。

一条链路的契约由三类字段组成:

字段类型判定缺失后果例子
必需且不可替代required: true,不允许降级整份结果拒绝交付风险等级、排序结果
必需但可替代required: true,允许降级可交付,但必须标注摘要、推荐说明
可选required: false可交付,提示缺口要点、引用出处

「必需但可替代」是最容易被忽略的一类。它意味着字段的语义必须保留,但来源可以换:摘要是模板生成的,推荐说明是规则拼接的。替代产物必须带来源标记,否则下游无法区分「模型写的」与「模板写的」。

「可选」也不等于「无所谓」。引用出处缺失不影响答复成立,但用户会失去核对能力,因此仍要提示,只是提示强度低于降级。

三条链的字段契约

「回声」三条链的契约如下。它刻意保持很小:必需字段少而硬,可选字段多而软。

链路必需且不可替代必需但可替代可选
A 内容发布安全链risk_levelsummarykey_points、risk_flags
B 私信客服链answer无citations、intent
C 站内下单咨询链ranked_idsanswercitations

契约的差异来自业务后果,不来自模型能力。内容发布链如果缺风险等级,发布动作就失去依据,所以它不可替代;摘要缺失只影响可读性,所以允许模板替代。下单咨询链如果缺排序结果,推荐就变成了无依据的说明,因此 ranked_ids 不可替代,而说明文字可以降级。

三档验收与四级提示

验收结果只有三档,避免出现「大概可用」这种无法执行的中间态:

状态条件可交付提示级别
complete所有必需字段原样存在是none 或 notice
partial必需字段齐全,但至少一个用了降级产物是banner
rejected任一必需字段缺失或替代不被允许否block

四级提示对应四种用户界面行为。none 不显示任何东西;notice 在结果下方给一行说明;banner 在结果上方常驻一条降级提示;block 不展示结果,只展示失败原因与下一步动作。

complete 仍可能带 notice:可选字段缺失时主结果完整,但用户应知道少了什么。把「完整」与「无提示」绑定在一起,是部分结果验收里最常见的漏洞。

实现

下面的验收器只做三件事:按契约逐字段对账、计算交付状态、生成用户可读说明。它不调用模型,也不决定失败后是否重试,因此可以独立测试。

// file: src/orch/partial.ts
// tier: L1
// verify: node --test test/orch/failure.test.ts

import { AiError } from "../core/errors.ts";
import type { ChainId } from "./workflow-shape.ts";

/** 字段有三种去向:原样交付、使用降级产物、缺失。 */
export type FieldState = "present" | "degraded" | "missing";

export interface FieldRule {
  name: string;
  required: boolean;
  /** 缺失时的处置:中止、接受降级替代、或直接省略。 */
  onMissing: "abort" | "degrade" | "omit";
  /** 给用户看的字段名,用于生成降级说明。 */
  label: string;
}

/** 一条链路的交付契约:哪些字段必须有,哪些可以缺。 */
export interface PartialContract {
  chainId: ChainId;
  fields: readonly FieldRule[];
}

export interface ProducedField {
  name: string;
  state: FieldState;
  /** 降级产物的来源,例如 template、cache、neighbor-chain。 */
  source?: string;
  /** 该字段自身的置信度,缺省时按 present=1、degraded=0.6 计。 */
  confidence?: number;
}

export interface PartialVerdict {
  chainId: ChainId;
  status: "complete" | "partial" | "rejected";
  /** 是否允许把这份结果交给用户或下游。 */
  deliverable: boolean;
  missingRequired: string[];
  missingOptional: string[];
  degraded: string[];
  delivered: string[];
  /** 用户可见标记:无、提示、横幅、阻断。 */
  notice: "none" | "notice" | "banner" | "block";
  /** 已交付字段占契约字段的比例,含降级交付。 */
  coverage: number;
  /** 按字段置信度求平均,缺失字段记 0。 */
  confidence: number;
  /** 可直接展示给用户的说明,不允许静默降级。 */
  message: string;
}

/** 「回声」三条链的交付契约:必须字段少而硬,可缺字段多而软。 */
export const ECHO_PARTIAL_CONTRACTS: Record<ChainId, PartialContract> = {
  A: {
    chainId: "A",
    fields: [
      { name: "summary", required: true, onMissing: "degrade", label: "摘要" },
      { name: "risk_level", required: true, onMissing: "abort", label: "风险等级" },
      { name: "key_points", required: false, onMissing: "omit", label: "要点" },
      { name: "risk_flags", required: false, onMissing: "omit", label: "风险标记" },
    ],
  },
  B: {
    chainId: "B",
    fields: [
      { name: "answer", required: true, onMissing: "abort", label: "答复" },
      { name: "citations", required: false, onMissing: "omit", label: "引用出处" },
      { name: "intent", required: false, onMissing: "omit", label: "意图" },
    ],
  },
  C: {
    chainId: "C",
    fields: [
      { name: "ranked_ids", required: true, onMissing: "abort", label: "排序结果" },
      { name: "answer", required: true, onMissing: "degrade", label: "推荐说明" },
      { name: "citations", required: false, onMissing: "omit", label: "引用出处" },
    ],
  },
};

function fail(message: string): never {
  throw new AiError("invalid-request", `partial: ${message}`);
}

function validate(contract: PartialContract, produced: readonly ProducedField[]): void {
  if (contract.fields.length === 0) fail(`${contract.chainId} 的契约不能为空`);
  const rules = new Set<string>();
  for (const rule of contract.fields) {
    if (rule.name.trim() === "") fail("字段名不能为空");
    if (rules.has(rule.name)) fail(`${contract.chainId} 重复声明字段 ${rule.name}`);
    rules.add(rule.name);
    if (rule.required && rule.onMissing === "omit") {
      fail(`${rule.name} 是必需字段,不能省略`);
    }
  }
  const seen = new Set<string>();
  for (const field of produced) {
    if (!rules.has(field.name)) fail(`${contract.chainId} 收到契约外字段 ${field.name}`);
    if (seen.has(field.name)) fail(`${contract.chainId} 重复产出字段 ${field.name}`);
    seen.add(field.name);
    if (field.confidence !== undefined && (!Number.isFinite(field.confidence) || field.confidence < 0 || field.confidence > 1)) {
      fail(`${field.name}.confidence 必须落在 [0, 1]`);
    }
  }
}

/**
 * 验收只看契约,不看上游「尽力了没有」:
 * 必需字段全在(含允许的降级替代)才可交付,否则直接拒绝。
 */
export function acceptPartial(
  contract: PartialContract,
  produced: readonly ProducedField[],
): PartialVerdict {
  validate(contract, produced);
  const byName = new Map(produced.map((field) => [field.name, field]));
  const missingRequired: string[] = [];
  const missingOptional: string[] = [];
  const degraded: string[] = [];
  const delivered: string[] = [];
  let confidenceSum = 0;

  for (const rule of contract.fields) {
    const field = byName.get(rule.name);
    const state: FieldState = field?.state ?? "missing";

    if (state === "present") {
      delivered.push(rule.name);
      confidenceSum += field?.confidence ?? 1;
      continue;
    }

    if (state === "degraded" && rule.onMissing === "degrade") {
      delivered.push(rule.name);
      degraded.push(rule.name);
      confidenceSum += field?.confidence ?? 0.6;
      continue;
    }

    if (rule.required) {
      missingRequired.push(rule.name);
      continue;
    }
    missingOptional.push(rule.name);
  }

  const status: PartialVerdict["status"] = missingRequired.length > 0
    ? "rejected"
    : degraded.length > 0
      ? "partial"
      : "complete";
  const notice: PartialVerdict["notice"] = status === "rejected"
    ? "block"
    : status === "partial"
      ? "banner"
      : missingOptional.length > 0
        ? "notice"
        : "none";
  const labels = new Map(contract.fields.map((rule) => [rule.name, rule.label]));

  return {
    chainId: contract.chainId,
    status,
    deliverable: status !== "rejected",
    missingRequired,
    missingOptional,
    degraded,
    delivered,
    notice,
    coverage: Number((delivered.length / contract.fields.length).toFixed(4)),
    confidence: Number((confidenceSum / contract.fields.length).toFixed(4)),
    message: buildMessage(status, labels, missingRequired, missingOptional, degraded),
  };
}

function buildMessage(
  status: PartialVerdict["status"],
  labels: Map<string, string>,
  missingRequired: readonly string[],
  missingOptional: readonly string[],
  degraded: readonly string[],
): string {
  const names = (items: readonly string[]): string =>
    items.map((name) => labels.get(name) ?? name).join("、");

  if (status === "rejected") {
    return `结果不可交付:缺少必需字段 ${names(missingRequired)}。`;
  }
  if (status === "partial") {
    return `部分结果可用:${names(degraded)}使用降级内容,已标注。`;
  }
  if (missingOptional.length > 0) {
    return `主结果完整,可选的${names(missingOptional)}暂缺。`;
  }
  return "结果完整,可直接使用。";
}

coverage 与 confidence 是两个不同口径。前者是「契约字段里有多少被交付」,后者是「交付内容平均有多可信」:原样字段记 1,降级字段记 0.6,缺失字段记 0。覆盖率高的结果不一定置信度高,例如四个字段里有一个降级、其余正常,覆盖率是 100%,置信度只有 0.9。

message 由契约里的 label 生成,而不是拼接英文字段名。用户看到的是「风险等级」,不是 risk_level;这一步放在验收器里,可以保证所有链路用同一套措辞。

契约外字段会直接抛错,而不是静默忽略。上游多产出了一个字段,说明契约与实现已经漂移;此时接受结果,下游可能拿到未经校验的内容。

示例样本上的运行结果

用五组构造输入覆盖三条链,验收结果如下:

场景状态缺失必需缺失可选提示覆盖率置信度
A 全字段正常complete无无none1.001.00
A 摘要用模板替代partial无无banner1.000.90
A 缺风险等级rejectedrisk_levelrisk_flagsblock0.500.50
B 缺引用出处complete无citationsnotice0.670.67
C 缺排序结果rejectedranked_idscitationsblock0.330.20

五组里四组可交付,一组拒绝,可交付率 80%。这个比例只说明构造样本覆盖了三条分支,不能当成线上可用率。

值得注意的是第二行与第三行的差别:两者都有字段不完整,但摘要允许替代,风险等级不允许。前者覆盖率仍是 1.00,状态是 partial;后者覆盖率掉到 0.50,状态是 rejected。决定状态的不是「缺了多少」,而是「缺的是不是不可替代的必需字段」。

第五行还说明一个细节:answer 用了降级产物,citations 缺失,但真正导致拒绝的是 ranked_ids。降级字段会被记录下来,却不是拒绝的原因;验收器必须把「已降级」与「必需缺失」分开报告,否则排障时会把注意力放错地方。

用户看到什么

三档状态对应三套文案,文案由验收器统一生成:

  • complete + none:结果完整,可直接使用。
  • complete + notice:主结果完整,可选的引用出处暂缺。
  • partial + banner:部分结果可用,摘要使用降级内容,已标注。
  • rejected + block:结果不可交付,缺少必需字段风险等级。

除了文案,系统还需要机器可读的标记。至少三个字段要进入响应:status、degraded[]、missingRequired[]。前端据此决定是否展示横幅,下游据此决定是否继续调用,审计据此判断这次交付是否合规。

降级字段还要带 source,例如 template、cache、neighbor-chain。只写「已降级」不够,事故复盘时无法回答「降级内容从哪里来」。

验收清单

把部分结果纳入发布门禁,可以用五条判据:

  1. 每条链都有一份显式契约,必需、可替代、可选三类字段齐全。
  2. 任何降级字段都有来源标记,且来源在允许列表内。
  3. 任一必需字段缺失时,status 必须是 rejected,不能降级成 complete。
  4. 可选字段缺失必须产生 notice,不能静默省略。
  5. 用户文案与机器标记来自同一次验收结果,不能各算各的。

这五条都可以在单元测试里判定,不需要真实模型。它们约束的是编排器的交付行为,而不是模型质量。

小结

部分可用不是「差不多能用」,而是一份按字段契约算出来的验收结论。契约把字段分成必需且不可替代、必需但可替代、可选三类;验收器据此给出 complete、partial、rejected 三档状态,以及 none、notice、banner、block 四级提示。

五组示例输入里四组可交付、一组拒绝。真正决定状态的是「缺失的字段是否不可替代」,而不是缺失数量。降级字段必须带来源标记,必需字段缺失必须拒绝,可选字段缺失必须提示——这三条让部分结果从形容词变成了可执行的门禁。

验收器识别出的缺口与降级来源,接下来要进入 5.3 的预算问题:为了少产生这些缺口,全局重试预算应该怎么分、什么时候该熔断止损。

阅读导航:上一节:5.1 失败分类与处理策略 · 下一节:5.3 编排级重试预算与熔断 。

常见错误与边界

  • 把「有输出」当成「可交付」。模板拼接的空壳也是输出,但不一定是可用结果。
  • 只返回一个成功布尔值。下游无法区分完整、降级与拒绝。
  • 必需字段缺失时用空字符串占位。这会让拒绝变成静默的坏数据。
  • 可选字段缺失不提示。用户不知道引用出处已经丢失。
  • 降级字段不记来源。复盘时无法判断替代内容是否合规。
  • 覆盖率与置信度混为一个指标。覆盖 100% 的结果仍可能整体降级。
  • 契约在上游实现里,而不在编排层。上游改字段时,验收器不会跟着变。
  • 接受契约外字段。字段漂移被静默吞掉,下游可能消费未校验内容。
  • 把示例样本的 80% 可交付率当成线上 SLA。它只是五组构造输入的覆盖结果。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查