一个入口,四种不确定性
「回声」把多能力编排包装成一个接口,业务方只调用 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 | 对应哪次请求 | 必须与请求一致 |
| status | succeeded、partial、failed、cancelled | 四选一 |
| output | 可用输出 | 对象;partial 时可能是部分结果 |
| nodes | 每个节点的状态、成本与延迟 | nodeId 唯一,成本非负 |
| usage | 汇总 token 与成本 | 三个计数均为非负整数 |
| cursor | 续跑位置 | 可选;只记录位置,不承载业务状态 |
| error | 错误码、是否可重试、说明 | 与 status 自洽 |
| idempotencyKey | 对应哪次幂等请求 | 必须与请求一致 |
status 与 error 必须一起读。succeeded 不允许带错误,也不允许存在 failed 节点;failed 必须带错误;partial 必须带一个允许部分结果的错误码;cancelled 必须带 cancelled。这四条规则让调用方不需要解析自然语言,就能决定展示、重试还是放弃。
八类错误码
错误码不是越多越好,但少到无法区分「重试有用」与「重试有害」也不行。示例保留八类,每一类都绑定固定的 HTTP 语义、重试策略与部分结果策略。
| 错误码 | HTTP | 可重试 | 可带部分结果 | 典型场景 |
|---|---|---|---|---|
| invalid-envelope | 400 | 否 | 否 | 请求缺字段或字段非法 |
| contract-mismatch | 409 | 否 | 否 | 双方契约版本不兼容 |
| node-failed | 502 | 是 | 是 | 某个节点失败,其余节点有产出 |
| partial-result | 206 | 否 | 是 | 任务完成一部分,剩余需续跑 |
| budget-exceeded | 402 | 否 | 是 | 预算耗尽,已有部分结果 |
| deadline-exceeded | 504 | 是 | 是 | 截止时间到,已有部分结果 |
| cancelled | 499 | 否 | 是 | 调用方主动取消 |
| internal | 500 | 是 | 否 | 编排层自身异常 |
invalid-envelope 不可重试,是这张表最重要的一行。请求结构错了,重试只会得到同样的 400,还会放大流量。反过来,deadline-exceeded 可重试,但调用方要先确认剩余预算与截止时间,不能原样重放。
版本演进:次版本加字段,大版本改语义
版本策略只有三条:补丁版本不改变语义;次版本只新增可选字段,不改必填性与字段含义;大版本才允许删除字段、改必填性或改语义。违反这三条,版本号就失去意义。
| 发送方 | 接收方 | 判定 | 处理 |
|---|---|---|---|
| 2.3.0 | 2.3.0 | exact | 正常处理 |
| 2.2.0 | 2.3.0 | backward-compatible | 缺少新可选字段,按默认值处理 |
| 2.4.0 | 2.3.0 | sender-newer-minor | 拒绝,提示升级接收方 |
| 3.0.0 | 2.3.0 | major-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。响应体积与内部结构都会失控。
- 没有幂等键。重试与重复提交会让同一次任务执行多次。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。