一条链路上丢掉的三个字段
「回声」的客服辅助链路是 search.rerank → content.summarize → chat.reply。上线第二周,坐席反馈三类问题:回复引用了已经下架的退款政策;摘要回答的是另一个订单;会话超过十轮后,回复突然忘记前面确认过的金额。
排查发现,三个节点各自都在正常工作,坏在节点之间传了什么。检索节点产出了文档 id 和 revision,但摘要节点只收到一段纯文本,revision 丢了;摘要节点算出了摘要,但回复节点没有收到 policyVersion,只能按当前政策回答;长会话的检查点没有随调用传递,回复节点从第一轮重新开始。三个字段,三种丢失方式,对应三种不同的传输载体。
卷二 13.3 留下的第一个问题正是:哪些上下文必须随调用传递,哪些只传引用、摘要或游标。本节不讨论「多传一点更保险」,而是把每个字段归到一类载体,并让校验器决定它能不能进入下一步。文中的校验结果来自 ai-lab 的示例样本运行输出,不是生产实测。
四类载体,四种失败方式
节点间只传四类上下文。每一类都解决一种信息形态,也都对应一种典型失败。
| 载体 | 传什么 | 典型字段 | 丢失后的表现 |
|---|---|---|---|
| payload | 下游完成当前步骤必需的数据 | query、locale、policyVersion | 下游无法开始,或按默认值算错 |
| reference | id、revision 与 checksum | sourceDoc、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 上限。上游压缩越多,下游丢掉的信息越难被发现。
- 游标当状态传。游标只该记录位置,不该承载需要完整语义的业务数据。
- 忽略共享块。只传交接包看似便宜,但下游可能看不到政策与系统约束。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。