《互联网产品的 AI 架构·多能力编排与任务闭环》2.1 能力之间的数据契约

卷三要回答的第一个问题是跨能力调用到底该传什么。本节把节点间上下文分成携带数据、传引用、传摘要、传游标四类载体,给出字段来源、必填、版本与校验规则,并用一个零依赖校验器把缺失、未知字段、失效引用和越界游标变成可定位的问题码,附可复算的校验结果。

一条链路上丢掉的三个字段

「回声」的客服辅助链路是 search.rerank → content.summarize → chat.reply。上线第二周,坐席反馈三类问题:回复引用了已经下架的退款政策;摘要回答的是另一个订单;会话超过十轮后,回复突然忘记前面确认过的金额。

排查发现,三个节点各自都在正常工作,坏在节点之间传了什么。检索节点产出了文档 id 和 revision,但摘要节点只收到一段纯文本,revision 丢了;摘要节点算出了摘要,但回复节点没有收到 policyVersion,只能按当前政策回答;长会话的检查点没有随调用传递,回复节点从第一轮重新开始。三个字段,三种丢失方式,对应三种不同的传输载体。

卷二 13.3 留下的第一个问题正是:哪些上下文必须随调用传递,哪些只传引用、摘要或游标。本节不讨论「多传一点更保险」,而是把每个字段归到一类载体,并让校验器决定它能不能进入下一步。文中的校验结果来自 ai-lab 的示例样本运行输出,不是生产实测。

四类载体,四种失败方式

节点间只传四类上下文。每一类都解决一种信息形态,也都对应一种典型失败。

载体传什么典型字段丢失后的表现
payload下游完成当前步骤必需的数据query、locale、policyVersion下游无法开始,或按默认值算错
referenceid、revision 与 checksumsourceDoc、orderSnapshot下游读到旧版本或读不到内容
summary上游压缩过的语义conversationSummary长会话丢上文,短会话白付 token
cursor续跑位置,不传全量状态resumeCursor、streamOffset重复执行或从错误位置继续

四类载体的成本与完整性不同。payload 最可靠也最贵,因为它每次都要复制;reference 最便宜,但要求共享存储可解析且 revision 稳定;summary 省 token,却把「哪些细节被压掉」的责任交给了上游;cursor 只适合可重放的长任务,不适合需要完整语义的生成步骤。

一个字段归到哪一类,不由它的数据类型决定,而由下游「拿不到它时能不能继续」决定。query 是字符串,sourceDoc 也是字符串,但前者必须复制,后者只该传引用。把引用当 payload 复制,会把文档内容重复计费;把 payload 当引用传,下游会拿到一个解析不了的 id。

同一条链路里的旁路节点也适用同一套规则。moderation.classify 只消费 summary 与 sourceDoc 的 reference,产出 label 与 confidence;它不需要完整共享上下文,但必须拿到与摘要节点相同的 revision,否则会把旧文档判成新文档。

字段要写清六件事

只写字段名和类型不够。一个可执行的节点契约至少要说清六件事:是否必填、由谁产生、从哪个版本开始有、最后一次语义变更在哪个版本、违反时默认怎么处理,以及它走哪类载体。

契约项作用缺失后的后果
name下游按名取值字段名靠口头约定,改名即断链
carrier决定复制、引用、摘要还是游标该传引用的被整段复制,成本失真
required决定缺失时拒绝还是降级可选与必填混在一起,失败不可判定
source出问题时可回溯到生产节点只能从整条链路重新排查
since / changedIn支持跨版本迁移与灰度新旧节点混跑时语义漂移
onViolation统一 reject、degrade、repair每个节点各写一套兜底

onViolation 不是装饰。安全相关字段违反时应当 reject;locale 这类字段可以 degrade 到租户默认值;摘要超长可以 repair 成截断版本。把默认动作写进契约,编排层才能在失败传播时做出统一决策,而不是在每个节点里复制一段 if。

版本不是文件名,是语义承诺

契约版本用 major.minor.patch。大版本表示字段语义或必填性发生破坏性变化;次版本表示只新增可选字段;补丁版本不改变语义。这条规则听起来简单,但它决定了两个节点能不能直接通信。

发送方次版本高于接收方时,接收方可能不认识新字段。严格校验器会把新字段判为未知字段,于是直接失败。发送方次版本低于接收方时,接收方看到的是「缺少新加的可选字段」,按向后兼容处理。大版本不同则一律拒绝,不做猜测。

版本判定的方向容易被写反。校验时比较的是「信封的生产者版本」与「当前节点的契约版本」,不是两个节点的产品版本。一个节点可以同时消费多个版本的输入,但它只能按自己声明的契约版本解释字段。

实现

下面的模块把四类载体、字段元数据、信封结构与校验器放在一起。校验器只收集问题,不抛异常:节点输出不合契约是预期事件,应该由编排层决定拒绝、降级还是修复。问题带绝对路径并按路径与问题码排序,保证同一输入每次得到同一份报告。

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

import { AiError } from "../core/errors.ts";

/** 四类载体:payload 必传,reference 传指针,summary 传压缩语义,cursor 传续跑位置。 */
export type ContextCarrier = "payload" | "reference" | "summary" | "cursor";

export interface ContractField {
  name: string;
  carrier: ContextCarrier;
  required: boolean;
  source: string;
  since: string;
  changedIn: string;
  onViolation: "reject" | "degrade" | "repair";
}

export interface NodeContract {
  nodeId: string;
  capability: string;
  version: string;
  fields: readonly ContractField[];
  /** 必须能在外存中解析到同一 revision 的引用 id。 */
  references: readonly string[];
  /** 不在节点间复制、只按前缀复用的稳定上下文块。 */
  sharedBlocks: readonly string[];
  maxSummaryTokens: number;
  maxCursorBytes: number;
}

export interface PayloadReference {
  id: string;
  revision: string;
  checksum: string;
}

export interface NodeEnvelope {
  contractVersion: string;
  runId: string;
  from: string;
  to: string;
  payload: Record<string, unknown>;
  references: readonly PayloadReference[];
  summary?: { text: string; tokens: number };
  cursor?: { step: string; offset: number };
  budgetMicros: number;
  deadlineMs: number;
}

export type ContractIssueCode =
  | "missing-field"
  | "unknown-field"
  | "reference-missing"
  | "reference-unknown"
  | "reference-invalid"
  | "reference-duplicate"
  | "summary-too-long"
  | "cursor-too-large"
  | "budget-negative"
  | "deadline-invalid"
  | "version-mismatch";

export interface ContractIssue {
  path: string;
  code: ContractIssueCode;
  expected: string;
  actual: string;
}

export type VersionDecision =
  | { ok: true; mode: "exact" | "backward-compatible" }
  | { ok: false; reason: "major-mismatch" | "sender-newer-minor" | "invalid-version"; detail: string };

const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;

export function parseVersion(
  version: string,
): { major: number; minor: number; patch: number } | null {
  const match = VERSION.exec(version);
  return match === null ? null : { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
}

/** 大版本必须相同;发送方次版本更高时接收方可能不认识新字段,直接拒绝。 */
export function checkVersionCompatibility(producer: string, consumer: string): VersionDecision {
  const left = parseVersion(producer);
  const right = parseVersion(consumer);
  if (left === null || right === null) {
    return { ok: false, reason: "invalid-version", detail: `producer=${producer} consumer=${consumer}` };
  }
  if (left.major !== right.major) {
    return { ok: false, reason: "major-mismatch", detail: `producer=${producer} consumer=${consumer}` };
  }
  if (left.minor > right.minor) {
    return { ok: false, reason: "sender-newer-minor", detail: `producer=${producer} consumer=${consumer}` };
  }
  return { ok: true, mode: left.minor < right.minor ? "backward-compatible" : "exact" };
}

function push(issues: ContractIssue[], path: string, code: ContractIssueCode, expected: string, actual: unknown): void {
  issues.push({ path, code, expected, actual: String(actual) });
}

function compareText(a: string, b: string): number {
  return a < b ? -1 : a > b ? 1 : 0;
}

/** 校验只收集问题、不抛异常:节点输出不合契约是预期事件。 */
export function validateEnvelope(contract: NodeContract, envelope: NodeEnvelope): ContractIssue[] {
  const issues: ContractIssue[] = [];
  const declared = new Map(contract.fields.map((field) => [field.name, field]));

  if (envelope.runId.trim() === "") push(issues, "$.runId", "missing-field", "non-empty", envelope.runId);
  if (envelope.from.trim() === "") push(issues, "$.from", "missing-field", "non-empty", envelope.from);
  if (envelope.to.trim() === "") push(issues, "$.to", "missing-field", "non-empty", envelope.to);
  if (!checkVersionCompatibility(envelope.contractVersion, contract.version).ok) {
    push(issues, "$.contractVersion", "version-mismatch", contract.version, envelope.contractVersion);
  }
  if (!Number.isInteger(envelope.budgetMicros) || envelope.budgetMicros < 0) {
    push(issues, "$.budgetMicros", "budget-negative", "integer >= 0", envelope.budgetMicros);
  }
  if (!Number.isFinite(envelope.deadlineMs) || envelope.deadlineMs <= 0) {
    push(issues, "$.deadlineMs", "deadline-invalid", "finite > 0", envelope.deadlineMs);
  }

  for (const field of contract.fields) {
    if (!field.required) continue;
    if (field.carrier === "payload") {
      const value = envelope.payload[field.name];
      if (value === undefined || value === null || value === "") {
        push(issues, `$.payload.${field.name}`, "missing-field", "present", "missing");
      }
    } else if (field.carrier === "reference") {
      if (!envelope.references.some((reference) => reference.id === field.name)) {
        push(issues, `$.references.${field.name}`, "reference-missing", "present", "missing");
      }
    } else if (field.carrier === "summary") {
      if (envelope.summary === undefined || envelope.summary.text.trim() === "") {
        push(issues, "$.summary", "missing-field", "present", "missing");
      }
    } else if (envelope.cursor === undefined) {
      push(issues, "$.cursor", "missing-field", "present", "missing");
    }
  }

  for (const key of Object.keys(envelope.payload)) {
    if (!declared.has(key)) push(issues, `$.payload.${key}`, "unknown-field", "declared field", key);
  }

  if (envelope.summary !== undefined) {
    const { text, tokens } = envelope.summary;
    if (text.trim() === "") push(issues, "$.summary.text", "missing-field", "non-empty", text);
    if (!Number.isInteger(tokens) || tokens < 0) {
      push(issues, "$.summary.tokens", "summary-too-long", "integer >= 0", tokens);
    } else if (tokens > contract.maxSummaryTokens) {
      push(issues, "$.summary.tokens", "summary-too-long", `<= ${contract.maxSummaryTokens}`, tokens);
    }
  }

  if (envelope.cursor !== undefined) {
    const bytes = new TextEncoder().encode(JSON.stringify(envelope.cursor)).length;
    if (bytes > contract.maxCursorBytes) {
      push(issues, "$.cursor", "cursor-too-large", `<= ${contract.maxCursorBytes} bytes`, bytes);
    }
  }

  const allowed = new Set(contract.references);
  const seen = new Set<string>();
  envelope.references.forEach((reference, index) => {
    const path = `$.references[${index}]`;
    if (reference.id.trim() === "" || reference.revision.trim() === "") {
      push(issues, path, "reference-invalid", "id and revision", reference.id);
      return;
    }
    if (!allowed.has(reference.id)) push(issues, path, "reference-unknown", "declared reference", reference.id);
    if (seen.has(reference.id)) push(issues, path, "reference-duplicate", "unique id", reference.id);
    seen.add(reference.id);
    if (reference.checksum.trim() === "") {
      push(issues, `${path}.checksum`, "reference-invalid", "non-empty", "missing");
    }
  });
  for (const id of contract.references) {
    if (!seen.has(id)) push(issues, `$.references.${id}`, "reference-missing", "present", "missing");
  }

  issues.sort((a, b) => compareText(a.path, b.path) || compareText(a.code, b.code));
  return issues;
}

export function describeIssues(issues: readonly ContractIssue[]): string {
  return issues.map((issue) => `${issue.path} ${issue.code} expected=${issue.expected} actual=${issue.actual}`).join("; ");
}

export function assertEnvelope(contract: NodeContract, envelope: NodeEnvelope): void {
  const issues = validateEnvelope(contract, envelope);
  if (issues.length > 0) throw new AiError("invalid-request", `handoff: ${describeIssues(issues)}`);
}

export interface TransportPlan {
  inline: string[];
  referenced: string[];
  summarized: string[];
  cursored: string[];
  /** 共享块为空时,只传交接包也能保证下游看到全部必需上下文。 */
  passSafe: boolean;
}

export function planTransport(contract: NodeContract): TransportPlan {
  const plan: TransportPlan = {
    inline: [],
    referenced: [],
    summarized: [],
    cursored: [],
    passSafe: contract.sharedBlocks.length === 0,
  };
  for (const field of contract.fields) {
    if (!field.required) continue;
    if (field.carrier === "payload") plan.inline.push(field.name);
    else if (field.carrier === "reference") plan.referenced.push(field.name);
    else if (field.carrier === "summary") plan.summarized.push(field.name);
    else plan.cursored.push(field.name);
  }
  return plan;
}

export function passModeAllowed(contract: NodeContract): boolean {
  return contract.sharedBlocks.length === 0;
}

export function summarizeContract(contract: NodeContract): {
  required: number;
  optional: number;
  references: number;
  sharedBlocks: number;
} {
  return {
    required: contract.fields.filter((field) => field.required).length,
    optional: contract.fields.filter((field) => !field.required).length,
    references: contract.references.length,
    sharedBlocks: contract.sharedBlocks.length,
  };
}

validateEnvelope 做四类检查:必填字段是否在其载体上出现、payload 是否混入未声明字段、引用是否唯一且带 checksum、摘要与游标是否越过上限。未知字段被严格拒绝,这是有意的:模型和上游节点都很擅长「顺手补一个字段」,一旦放行,下游就会开始依赖一个没有写进契约的值。

planTransport 把契约翻译成传输计划,回答「哪些字段复制、哪些传引用」。passSafe 则回答另一个问题:如果下游只收到 820 token 的交接包、不重新注入共享块,它是否仍能看到全部必需上下文。共享块不为空时,pass 模式不安全,必须由下游重新注入系统提示或政策文本。

示例样本上的运行结果

用「回声」客服链路的契约跑测试,结果如下。这些数字来自确定性样本,换一份契约就会变化。

场景校验结果
完整信封问题 0 个,assertEnvelope 不抛错
删除 payload.query$.payload.query missing-field
增加 payload.extra$.payload.extra unknown-field
摘要 301 token(上限 300)$.summary.tokens summary-too-long
游标 JSON 超过 512 字节$.cursor cursor-too-large
发送方 2.4.0、接收方 2.3.0$.contractVersion version-mismatch
同一输入重复校验两次报告逐项相同

传输计划的结果是:inline 3 个字段(query、locale、policyVersion),referenced 1 个(sourceDoc),summarized 1 个(conversationSummary),cursored 1 个(resumeCursor)。契约含 1 个共享块,因此 passSafe=false。这组数字说明:这条链路不能只传交接包,必须保留共享的政策上下文。

契约的取舍

把字段放进 payload 最安全,但成本最高;放进 reference 最省,但依赖共享存储与 revision 稳定性。示例契约选择了一个混合方案:三个下游无法自行恢复的字段走 payload,文档内容走 reference,长会话语义走 summary,续跑位置走 cursor。

这个选择有明确边界。如果下游无法访问共享存储,reference 会退化成「一个解析不了的 id」,此时只能把内容复制进 payload,成本随之上升。如果摘要节点无法保证压缩质量,summary 会把信息损失藏起来,reply 节点看起来成功,实际答非所问。契约能保证「字段到了」,不能保证「语义对了」,后者要靠质量评测补上。

小结

能力之间的数据契约不是字段清单,而是一组可判定的传输决定:每个字段走 payload、reference、summary 还是 cursor,是否必填,由谁产生,从哪个版本开始有,违反时怎么处理。四类载体对应四种成本与四种失败方式,选错载体,链路会在最不显眼的地方丢信息。

本节给出的校验器把缺失、未知字段、失效引用与越界游标变成带路径的问题码,并用版本兼容判定拦住新旧节点混跑。下一节换一个角度:当这些字段真的随调用传递时,一次交接到底要花多少 token、多少钱,复用率低到什么程度就不如直接复制。

阅读导航:上一节:1.3 什么该编排,什么该固定流程 · 下一节:2.2 交接成本与复用回本线 。

常见错误与边界

  • 只写字段名和类型。没有来源、版本与违反动作,契约无法驱动失败处理。
  • 把引用当 payload 复制。文档内容被重复计费,交接成本被低估。
  • 把 payload 当引用传。下游拿到一个解析不了的 id,只能猜或失败。
  • 允许多余字段。上游顺手补的字段一旦被依赖,就变成没有契约的隐式接口。
  • 版本只看大版本。次版本新增字段同样会让严格接收方失败。
  • 版本判定方向写反。比较的是信封生产者与当前节点契约,不是两个产品版本。
  • 摘要不设 token 上限。上游压缩越多,下游丢掉的信息越难被发现。
  • 游标当状态传。游标只该记录位置,不该承载需要完整语义的业务数据。
  • 忽略共享块。只传交接包看似便宜,但下游可能看不到政策与系统约束。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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