半份结果算不算结果
5.1 把失败分成了四类。可跳过与可降级这两类不会让链路停下,它们会让链路带着缺口继续。于是编排器必须回答一个比错误码更棘手的问题:缺口出现之后,这份结果还能不能交给用户?
「回声」的内容发布链给出过一个真实形状的难题。摘要节点失败两次后,模板生成了一个 200 字摘要;审核分类正常返回了风险等级;要点字段因为上游超时缺失。三个字段里,一个降级、一个正常、一个缺失。这条结果算成功、算部分成功,还是算失败?
如果编排器只返回一个 ok 布尔值,下游只能猜。客服链会把缺少引用出处的答复直接发给用户;下单咨询链会把没有排序结果的推荐说明当成完整答案。用户看到的是一段流畅的文字,不知道其中一部分是模板,另一部分根本不存在。
所以「部分可用」不能是一个形容词,必须是一组可判定的字段规则:哪些字段必须有,哪些可以缺,哪些允许用替代产物,缺了之后用户看到什么、系统如何标注。
本节的所有验收结果都来自 ai-lab 的示例样本运行,五组构造输入用于覆盖完整、部分、拒绝三条分支,不代表线上分布。
契约先于失败处理
字段契约应该在编排图设计时写死,而不是等失败发生后再临时决定。原因很简单:失败发生时,编排器没有足够信息判断「这个字段重不重要」,它只能查契约。
一条链路的契约由三类字段组成:
| 字段类型 | 判定 | 缺失后果 | 例子 |
|---|---|---|---|
| 必需且不可替代 | required: true,不允许降级 | 整份结果拒绝交付 | 风险等级、排序结果 |
| 必需但可替代 | required: true,允许降级 | 可交付,但必须标注 | 摘要、推荐说明 |
| 可选 | required: false | 可交付,提示缺口 | 要点、引用出处 |
「必需但可替代」是最容易被忽略的一类。它意味着字段的语义必须保留,但来源可以换:摘要是模板生成的,推荐说明是规则拼接的。替代产物必须带来源标记,否则下游无法区分「模型写的」与「模板写的」。
「可选」也不等于「无所谓」。引用出处缺失不影响答复成立,但用户会失去核对能力,因此仍要提示,只是提示强度低于降级。
三条链的字段契约
「回声」三条链的契约如下。它刻意保持很小:必需字段少而硬,可选字段多而软。
| 链路 | 必需且不可替代 | 必需但可替代 | 可选 |
|---|---|---|---|
| A 内容发布安全链 | risk_level | summary | key_points、risk_flags |
| B 私信客服链 | answer | 无 | citations、intent |
| C 站内下单咨询链 | ranked_ids | answer | citations |
契约的差异来自业务后果,不来自模型能力。内容发布链如果缺风险等级,发布动作就失去依据,所以它不可替代;摘要缺失只影响可读性,所以允许模板替代。下单咨询链如果缺排序结果,推荐就变成了无依据的说明,因此 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 | 无 | 无 | none | 1.00 | 1.00 |
| A 摘要用模板替代 | partial | 无 | 无 | banner | 1.00 | 0.90 |
| A 缺风险等级 | rejected | risk_level | risk_flags | block | 0.50 | 0.50 |
| B 缺引用出处 | complete | 无 | citations | notice | 0.67 | 0.67 |
| C 缺排序结果 | rejected | ranked_ids | citations | block | 0.33 | 0.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。只写「已降级」不够,事故复盘时无法回答「降级内容从哪里来」。
验收清单
把部分结果纳入发布门禁,可以用五条判据:
- 每条链都有一份显式契约,必需、可替代、可选三类字段齐全。
- 任何降级字段都有来源标记,且来源在允许列表内。
- 任一必需字段缺失时,
status必须是 rejected,不能降级成 complete。 - 可选字段缺失必须产生 notice,不能静默省略。
- 用户文案与机器标记来自同一次验收结果,不能各算各的。
这五条都可以在单元测试里判定,不需要真实模型。它们约束的是编排器的交付行为,而不是模型质量。
小结
部分可用不是「差不多能用」,而是一份按字段契约算出来的验收结论。契约把字段分成必需且不可替代、必需但可替代、可选三类;验收器据此给出 complete、partial、rejected 三档状态,以及 none、notice、banner、block 四级提示。
五组示例输入里四组可交付、一组拒绝。真正决定状态的是「缺失的字段是否不可替代」,而不是缺失数量。降级字段必须带来源标记,必需字段缺失必须拒绝,可选字段缺失必须提示——这三条让部分结果从形容词变成了可执行的门禁。
验收器识别出的缺口与降级来源,接下来要进入 5.3 的预算问题:为了少产生这些缺口,全局重试预算应该怎么分、什么时候该熔断止损。
阅读导航:上一节:5.1 失败分类与处理策略 · 下一节:5.3 编排级重试预算与熔断 。
常见错误与边界
- 把「有输出」当成「可交付」。模板拼接的空壳也是输出,但不一定是可用结果。
- 只返回一个成功布尔值。下游无法区分完整、降级与拒绝。
- 必需字段缺失时用空字符串占位。这会让拒绝变成静默的坏数据。
- 可选字段缺失不提示。用户不知道引用出处已经丢失。
- 降级字段不记来源。复盘时无法判断替代内容是否合规。
- 覆盖率与置信度混为一个指标。覆盖 100% 的结果仍可能整体降级。
- 契约在上游实现里,而不在编排层。上游改字段时,验收器不会跟着变。
- 接受契约外字段。字段漂移被静默吞掉,下游可能消费未校验内容。
- 把示例样本的 80% 可交付率当成线上 SLA。它只是五组构造输入的覆盖结果。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。