《互联网产品的 AI 架构·多能力编排与任务闭环》2.3 编排层的输入输出与版本

编排层对外只暴露一个入口,但输入字段、状态码、错误语义与契约版本任何一个没定清,接入方就会各自猜。本节给出一份可校验的编排输入输出信封、八类错误码及其重试与部分结果策略,并演示 2.3 与 2.4 次版本、2.x 与 3.x 大版本之间的兼容判定。

一个入口,四种不确定性

「回声」把多能力编排包装成一个接口,业务方只调用 support.assist,由编排层决定检索、摘要、回复怎么串。上线一周,接入方反馈了四个问题:新增的可选字段让一个严格解析的客户端直接报错;同一个失败场景,有的客户端收到 failed,有的收到 partial;invalid-envelope 被客户端当成可重试错误,重试了三次;两个团队对「部分结果能不能展示」给出了相反的产品行为。

四个问题指向同一件事:编排层对外的输入输出没有成为契约。内部节点之间的数据契约在 2.1 定义,交接成本在 2.2 算清,但对外只有一个入口时,业务方看到的不是节点,而是字段、状态码、错误语义与版本。任何一个含糊,接入方就会各自猜,猜法还不一样。

本节定义编排层的对外信封:请求要带什么、响应能承诺什么、八类错误码各自能不能重试、能不能带部分结果,以及版本怎么演进才不把老客户端打挂。文中的校验结果来自 ai-lab 的示例样本运行输出。

输入契约:业务方只需要说清五件事

一个编排请求看似字段很多,其实只回答五个问题:这是谁的任务、要做什么、输入是什么、预算多少、失败了怎么重放。其余字段都是这五件事的展开。

字段回答的问题校验规则
contractVersion双方按哪版语义说话major.minor.patch,必须可解析
runId这一次运行是谁非空,响应必须原样带回
tenantId哪个租户的预算与权限非空,不能由模型推断
intent要完成什么任务非空,对应能力目录中的意图
input任务输入必须是对象,不接受裸字符串
contextRefs需要哪些外部上下文id 与 revision 非空且不重复
deadlineMs最晚什么时候返回有限正数
maxCostMicros最多花多少钱正整数微元
mode上下文用 copy、pass 还是 share三选一,默认 share
idempotencyKey重放时怎么识别同一次请求非空,响应必须原样带回

输出契约:承诺什么,不承诺什么

响应不是「一段文本」,而是一份可判定的运行报告。它要告诉调用方:这次运行是什么状态、产出了什么、每个节点花了多少、还能不能续跑、失败时该不该重试。

字段含义约束
contractVersion响应按哪版语义生成不得高于请求版本
runId对应哪次请求必须与请求一致
statussucceeded、partial、failed、cancelled四选一
output可用输出对象;partial 时可能是部分结果
nodes每个节点的状态、成本与延迟nodeId 唯一,成本非负
usage汇总 token 与成本三个计数均为非负整数
cursor续跑位置可选;只记录位置,不承载业务状态
error错误码、是否可重试、说明与 status 自洽
idempotencyKey对应哪次幂等请求必须与请求一致

status 与 error 必须一起读。succeeded 不允许带错误,也不允许存在 failed 节点;failed 必须带错误;partial 必须带一个允许部分结果的错误码;cancelled 必须带 cancelled。这四条规则让调用方不需要解析自然语言,就能决定展示、重试还是放弃。

八类错误码

错误码不是越多越好,但少到无法区分「重试有用」与「重试有害」也不行。示例保留八类,每一类都绑定固定的 HTTP 语义、重试策略与部分结果策略。

错误码HTTP可重试可带部分结果典型场景
invalid-envelope400否否请求缺字段或字段非法
contract-mismatch409否否双方契约版本不兼容
node-failed502是是某个节点失败,其余节点有产出
partial-result206否是任务完成一部分,剩余需续跑
budget-exceeded402否是预算耗尽,已有部分结果
deadline-exceeded504是是截止时间到,已有部分结果
cancelled499否是调用方主动取消
internal500是否编排层自身异常

invalid-envelope 不可重试,是这张表最重要的一行。请求结构错了,重试只会得到同样的 400,还会放大流量。反过来,deadline-exceeded 可重试,但调用方要先确认剩余预算与截止时间,不能原样重放。

版本演进:次版本加字段,大版本改语义

版本策略只有三条:补丁版本不改变语义;次版本只新增可选字段,不改必填性与字段含义;大版本才允许删除字段、改必填性或改语义。违反这三条,版本号就失去意义。

发送方接收方判定处理
2.3.02.3.0exact正常处理
2.2.02.3.0backward-compatible缺少新可选字段,按默认值处理
2.4.02.3.0sender-newer-minor拒绝,提示升级接收方
3.0.02.3.0major-mismatch拒绝,走迁移或双版本并行

响应方向的规则更严:响应版本不得高于请求版本。客户端按 2.3 的语义发请求,服务端不能回一份 2.4 的响应,否则客户端可能把新增字段当成未知字段。服务端要支持多版本,就应该按请求版本降级输出,而不是让客户端追着升级。

实现

下面的模块把请求门禁、响应门禁、状态推导与错误码策略放在一起。它不执行编排,只回答「这份信封能不能收」与「这个状态自不自洽」。

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

import { AiError } from "../core/errors.ts";
import { checkVersionCompatibility, parseVersion } from "./handoff.ts";

export type RunStatus = "succeeded" | "partial" | "failed" | "cancelled";

export type OrchestrationErrorCode =
  | "invalid-envelope"
  | "contract-mismatch"
  | "node-failed"
  | "partial-result"
  | "budget-exceeded"
  | "deadline-exceeded"
  | "cancelled"
  | "internal";

export interface ContextReference {
  id: string;
  revision: string;
}

/** 编排层对外唯一入口:业务方只描述意图、预算与上下文引用。 */
export interface OrchestrationRequest {
  contractVersion: string;
  runId: string;
  tenantId: string;
  intent: string;
  input: Record<string, unknown>;
  contextRefs: readonly ContextReference[];
  deadlineMs: number;
  maxCostMicros: number;
  mode: "copy" | "pass" | "share";
  idempotencyKey: string;
}

export interface NodeOutcome {
  nodeId: string;
  capability: string;
  status: "ok" | "degraded" | "failed" | "skipped";
  costMicros: number;
  latencyMs: number;
  errorCode?: OrchestrationErrorCode;
}

export interface OrchestrationResponse {
  contractVersion: string;
  runId: string;
  status: RunStatus;
  output: Record<string, unknown>;
  nodes: readonly NodeOutcome[];
  usage: { inputTokens: number; outputTokens: number; costMicros: number };
  cursor?: { step: string; offset: number };
  error?: { code: OrchestrationErrorCode; retryable: boolean; message: string };
  idempotencyKey: string;
}

export interface ErrorPolicy {
  httpStatus: number;
  retryable: boolean;
  /** 该错误是否允许携带部分结果返回。 */
  partialAllowed: boolean;
}

export const ERROR_POLICY: Record<OrchestrationErrorCode, ErrorPolicy> = {
  "invalid-envelope": { httpStatus: 400, retryable: false, partialAllowed: false },
  "contract-mismatch": { httpStatus: 409, retryable: false, partialAllowed: false },
  "node-failed": { httpStatus: 502, retryable: true, partialAllowed: true },
  "partial-result": { httpStatus: 206, retryable: false, partialAllowed: true },
  "budget-exceeded": { httpStatus: 402, retryable: false, partialAllowed: true },
  "deadline-exceeded": { httpStatus: 504, retryable: true, partialAllowed: true },
  cancelled: { httpStatus: 499, retryable: false, partialAllowed: true },
  internal: { httpStatus: 500, retryable: true, partialAllowed: false },
};

export type EnvelopeIssueCode = "missing" | "invalid" | "duplicate" | "version" | "status-inconsistent" | "mismatch";

export interface EnvelopeIssue {
  path: string;
  code: EnvelopeIssueCode;
  expected: string;
  actual: string;
}

const ERROR_CODES = new Set<string>(Object.keys(ERROR_POLICY));
const STATUSES = new Set<string>(["succeeded", "partial", "failed", "cancelled"]);
const MODES = new Set<string>(["copy", "pass", "share"]);

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

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function nonEmpty(value: string): boolean {
  return value.trim() !== "";
}

function sortIssues(issues: EnvelopeIssue[]): EnvelopeIssue[] {
  return issues.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
}

export function errorPolicy(code: OrchestrationErrorCode): ErrorPolicy {
  return ERROR_POLICY[code];
}

export function httpStatusFor(code: OrchestrationErrorCode): number {
  return ERROR_POLICY[code].httpStatus;
}

export function isRetryableCode(code: OrchestrationErrorCode): boolean {
  return ERROR_POLICY[code].retryable;
}

/** 请求门禁:版本、身份、预算、上下文引用与幂等键。 */
export function validateRequest(request: OrchestrationRequest): EnvelopeIssue[] {
  const issues: EnvelopeIssue[] = [];
  if (!nonEmpty(request.contractVersion) || parseVersion(request.contractVersion) === null) {
    push(issues, "$.contractVersion", "invalid", "major.minor.patch", request.contractVersion);
  }
  if (!nonEmpty(request.runId)) push(issues, "$.runId", "missing", "non-empty", request.runId);
  if (!nonEmpty(request.tenantId)) push(issues, "$.tenantId", "missing", "non-empty", request.tenantId);
  if (!nonEmpty(request.intent)) push(issues, "$.intent", "missing", "non-empty", request.intent);
  if (!isRecord(request.input)) push(issues, "$.input", "invalid", "object", typeof request.input);
  if (!Number.isFinite(request.deadlineMs) || request.deadlineMs <= 0) {
    push(issues, "$.deadlineMs", "invalid", "finite > 0", request.deadlineMs);
  }
  if (!Number.isInteger(request.maxCostMicros) || request.maxCostMicros <= 0) {
    push(issues, "$.maxCostMicros", "invalid", "integer > 0", request.maxCostMicros);
  }
  if (!MODES.has(request.mode)) push(issues, "$.mode", "invalid", "copy|pass|share", request.mode);
  if (!nonEmpty(request.idempotencyKey)) push(issues, "$.idempotencyKey", "missing", "non-empty", request.idempotencyKey);

  const seen = new Set<string>();
  request.contextRefs.forEach((reference, index) => {
    const path = `$.contextRefs[${index}]`;
    if (!nonEmpty(reference.id) || !nonEmpty(reference.revision)) {
      push(issues, path, "invalid", "id and revision", reference.id);
      return;
    }
    if (seen.has(reference.id)) push(issues, path, "duplicate", "unique id", reference.id);
    seen.add(reference.id);
  });
  return sortIssues(issues);
}

/** 响应门禁:状态与错误必须自洽,节点结果唯一,用量非负。 */
export function validateResponse(response: OrchestrationResponse, request?: OrchestrationRequest): EnvelopeIssue[] {
  const issues: EnvelopeIssue[] = [];
  if (!nonEmpty(response.contractVersion) || parseVersion(response.contractVersion) === null) {
    push(issues, "$.contractVersion", "invalid", "major.minor.patch", response.contractVersion);
  }
  if (!nonEmpty(response.runId)) push(issues, "$.runId", "missing", "non-empty", response.runId);
  if (!STATUSES.has(response.status)) push(issues, "$.status", "invalid", "succeeded|partial|failed|cancelled", response.status);
  if (!isRecord(response.output)) push(issues, "$.output", "invalid", "object", typeof response.output);
  if (!nonEmpty(response.idempotencyKey)) push(issues, "$.idempotencyKey", "missing", "non-empty", response.idempotencyKey);

  if (request !== undefined) {
    if (response.runId !== request.runId) push(issues, "$.runId", "mismatch", request.runId, response.runId);
    if (response.idempotencyKey !== request.idempotencyKey) {
      push(issues, "$.idempotencyKey", "mismatch", request.idempotencyKey, response.idempotencyKey);
    }
    if (nonEmpty(response.contractVersion) && parseVersion(response.contractVersion) !== null) {
      if (!checkVersionCompatibility(response.contractVersion, request.contractVersion).ok) {
        push(issues, "$.contractVersion", "version", request.contractVersion, response.contractVersion);
      }
    }
  }

  for (const key of ["inputTokens", "outputTokens", "costMicros"] as const) {
    const value = response.usage[key];
    if (!Number.isInteger(value) || value < 0) push(issues, `$.usage.${key}`, "invalid", "integer >= 0", value);
  }

  const seen = new Set<string>();
  response.nodes.forEach((node, index) => {
    const path = `$.nodes[${index}]`;
    if (!nonEmpty(node.nodeId) || !nonEmpty(node.capability)) {
      push(issues, path, "invalid", "nodeId and capability", node.nodeId);
      return;
    }
    if (seen.has(node.nodeId)) push(issues, `${path}.nodeId`, "duplicate", "unique", node.nodeId);
    seen.add(node.nodeId);
    if (!Number.isInteger(node.costMicros) || node.costMicros < 0) {
      push(issues, `${path}.costMicros`, "invalid", "integer >= 0", node.costMicros);
    }
    if (!Number.isFinite(node.latencyMs) || node.latencyMs < 0) {
      push(issues, `${path}.latencyMs`, "invalid", "finite >= 0", node.latencyMs);
    }
    if (node.errorCode !== undefined && !ERROR_CODES.has(node.errorCode)) {
      push(issues, `${path}.errorCode`, "invalid", "known code", node.errorCode);
    }
  });

  if (response.error !== undefined) {
    if (!ERROR_CODES.has(response.error.code)) {
      push(issues, "$.error.code", "invalid", "known code", response.error.code);
    } else if (response.error.retryable !== ERROR_POLICY[response.error.code].retryable) {
      push(issues, "$.error.retryable", "status-inconsistent", String(ERROR_POLICY[response.error.code].retryable), response.error.retryable);
    }
  }

  const failedNodes = response.nodes.filter((node) => node.status === "failed").length;
  if (response.status === "succeeded") {
    if (response.error !== undefined) push(issues, "$.error", "status-inconsistent", "absent", response.error.code);
    if (failedNodes > 0) push(issues, "$.status", "status-inconsistent", "no failed node", `${failedNodes} failed`);
  } else if (response.status === "failed") {
    if (response.error === undefined) push(issues, "$.error", "status-inconsistent", "present", "missing");
  } else if (response.status === "partial") {
    if (response.error === undefined) push(issues, "$.error", "status-inconsistent", "present", "missing");
    else if (!ERROR_POLICY[response.error.code].partialAllowed) {
      push(issues, "$.error.code", "status-inconsistent", "partial-allowed code", response.error.code);
    }
  } else if (response.status === "cancelled") {
    if (response.error === undefined || response.error.code !== "cancelled") {
      push(issues, "$.error.code", "status-inconsistent", "cancelled", response.error?.code ?? "missing");
    }
  }
  return sortIssues(issues);
}

export function assertRequest(request: OrchestrationRequest): void {
  const issues = validateRequest(request);
  if (issues.length > 0) throw new AiError("invalid-request", `envelope: ${describeEnvelopeIssues(issues)}`);
}

export function assertResponse(response: OrchestrationResponse, request?: OrchestrationRequest): void {
  const issues = validateResponse(response, request);
  if (issues.length > 0) throw new AiError("invalid-output", `envelope: ${describeEnvelopeIssues(issues)}`);
}

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

/** 有可用输出时,失败或降级降为 partial;没有可用输出时只能是 failed。 */
export function deriveStatus(nodes: readonly NodeOutcome[], hasUsableOutput: boolean): RunStatus {
  const degraded = nodes.some(
    (node) => node.status === "failed" || node.status === "degraded" || node.status === "skipped",
  );
  if (!degraded) return "succeeded";
  return hasUsableOutput ? "partial" : "failed";
}

/** 把内部错误码翻译成对外的 HTTP 语义。 */
export function toPublicError(code: OrchestrationErrorCode, message: string): {
  status: number;
  body: { code: OrchestrationErrorCode; retryable: boolean; message: string };
} {
  const policy = ERROR_POLICY[code];
  return { status: policy.httpStatus, body: { code, retryable: policy.retryable, message } };
}

validateRequest 与 validateResponse 都只收集问题、不抛异常,问题带路径并按路径排序。deriveStatus 把节点结果翻译成编排状态:只要还有可用输出,失败或降级就降为 partial;没有可用输出时只能是 failed。ERROR_POLICY 是唯一事实来源,HTTP 状态、重试与部分结果策略都从这里取,避免每个接入方各写一套映射。

示例样本上的运行结果

用一份 2.3 的请求与响应跑测试,结果如下。数字来自确定性样本,换一份信封就会变化。

场景结果
合法请求与响应问题 0 个,两个 assert 都不抛错
缺少 intent、预算为 0、幂等键为空分别定位到 $.intent、$.maxCostMicros、$.idempotencyKey
重复 contextRefs id$.contextRefs[1] duplicate
succeeded 却带 error$.error status-inconsistent
failed 却没有 error$.error status-inconsistent
partial 带 invalid-envelope$.error.code status-inconsistent
响应 runId 与请求不同$.runId mismatch
响应 2.4.0、请求 2.3.0$.contractVersion version

状态推导的三个样本:全部节点 ok 且有输出,返回 succeeded;一个节点 failed 但有可用输出,返回 partial;一个节点 failed 且没有可用输出,返回 failed。错误码映射的样本:node-failed 对应 502、可重试、可带部分结果;invalid-envelope 对应 400、不可重试。

幂等、游标与部分结果

idempotencyKey 不是可选优化。编排可能重试、可能超时、可能被调用方重复提交,没有幂等键,同一次任务会被执行多次,成本与副作用都会翻倍。响应必须原样带回幂等键,调用方才能确认「这是同一次运行的结果」。

小结

编排层的对外契约由三部分组成:输入信封定义业务方必须说清的五件事,输出信封定义运行报告能承诺什么,错误码与版本策略定义失败与演进时双方怎么行动。三者缺一,接入方就会用猜测填补空白。

本节给出的门禁把缺失字段、状态不一致、版本不兼容变成带路径的问题码;错误码表把重试与部分结果策略固定下来;版本规则把「加字段」与「改语义」分成两种发布动作。下一节进入执行结构:把编排写成图之后,节点顺序、并行汇合与检查点该如何表达。

阅读导航:上一节:2.2 交接成本与复用回本线 · 下一节:3.1 把编排写成图 。

常见错误与边界

  • 输入只校验类型,不校验语义。空 intent、重复引用与零预算都会让编排层无法执行。
  • 让模型推断 tenantId 或 intent。授权与路由不能交给自然语言。
  • 只给截止时间或只给预算。两个约束缺一个,编排层就会偷偷牺牲另一个。
  • succeeded 带 error。调用方无法判断到底成功还是失败。
  • partial 不带错误码。调用方不知道剩余部分能否续跑。
  • invalid-envelope 设为可重试。结构错误重试只会得到同样的 400。
  • 响应版本高于请求版本。严格客户端会把新增字段当成未知字段。
  • 次版本删除字段或改必填性。版本号还在,语义已经破坏。
  • 把完整中间结果塞进 cursor。响应体积与内部结构都会失控。
  • 没有幂等键。重试与重复提交会让同一次任务执行多次。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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