同一条失败,重跑三次三个结果
7.2 把失败归因到节点。值班同学拿着「answer 节点模型失败」的记录去复现:在测试环境重跑同一条输入,第一次成功,第二次引用错了文档,第三次超时。三次都「合理」,但没有一次能解释线上那次为什么失败。
原因不神秘:模型档位悄悄从 standard 换成了 economy,提示从 v3 升到了 v4,采样温度是 0.7 且没有固定种子,上游检索结果也在变。任何一项变化,都足以让重跑走出不同路径。不可复现不是因为模型「玄学」,而是因为复现时没有冻结当时的环境。
回放要解决的就是这件事:把一次链路执行压缩成一个足够小、又足够完整的包,让同一个包在任何时候都能重建同一条执行路径。文中数字来自 ai-lab 的示例回放运行结果,不是生产实测。
最小回放包
回放包不是全量日志。全量日志又大又含隐私,真正决定路径的只有几个字段:
| 字段 | 记录什么 | 缺了会怎样 |
|---|---|---|
| request | 能力名、租户、原始输入、截止时间 | 无法重建首节点输入 |
| workflowVersion | 编排图版本 | 节点顺序或分支条件可能已经变了 |
| modelTier | frontier / standard / economy | 换档位会直接改变输出分布 |
| promptVersion | 提示工件版本 | 提示改版是常见的静默变量 |
| sampling | 温度、随机种子、输出上限 | 没有种子只能得到「另一条合理路径」 |
| nodes | 每个节点的输入摘要、输出摘要、状态、延迟 | 无法定位从哪一步开始分歧 |
| result | 结果类别、总成本、端到端延迟 | 无法判断重放是否复现了失败 |
主场景的成功路径成本是 3,786 微元、延迟 1,269ms,这些值进包;高失败变体每次成功交付 4,139 微元,说明同一个包结构也能承载失败与重试成本。
只存必要信息
请求原文必须存,否则首节点无法重建;节点之间只存摘要,不存完整输入输出。摘要用 SHA-256 前 16 位十六进制,足以判断「是不是同一份内容」,又不会把合同、订单号、用户原话复制到调试系统里。
代价是摘要不能直接阅读。排查时先靠摘要定位分歧节点,再按 runId 从受控存储取该节点的短期原文。这个设计把「可复现」和「少存隐私」放在一起,而不是二选一。
规范化序列化
回放包要能比较,序列化就必须确定:对象键按字典序排列,数组保持原顺序,固定 schemaVersion,末尾保留一个换行。同样的包无论序列化多少次,字节都相同;不同的包才会产生差异。
没有规范化,两个语义相同的包会因为键顺序不同而比较失败,回放对比会退化成噪音。规范化也让包可以直接做快照测试和内容寻址存储。
实现
replay.ts 提供三件事:摘要、规范序列化/反序列化、逐字段对比。校验放在序列化与反序列化两侧,坏包不会进入对比。
// file: src/orch/replay.ts
// tier: L2
// verify: node --test test/orch/eval.test.ts
import { createHash } from "node:crypto";
import { AiError } from "../core/errors.ts";
export type ReplayOutcome = "success" | "partial" | "failure" | "timeout" | "unauthorized";
export type ModelTier = "frontier" | "standard" | "economy";
export type ReplayNodeStatus = "ok" | "failed" | "timeout" | "skipped" | "denied";
export interface ReplayRequest {
capability: string;
tenantId: string;
/** 原始请求文本;回放包要能重建首节点输入,不能只存摘要。 */
input: string;
deadlineMs: number;
}
export interface ReplayNode {
nodeId: string;
inputDigest: string;
outputDigest: string;
status: ReplayNodeStatus;
latencyMs: number;
}
export interface ReplaySampling {
temperature: number;
/** 固定随机种子;没有它,重放只能得到“另一条合理路径”。 */
seed: number;
maxOutputTokens: number;
}
/** 一条链路的最小回放包:请求、节点输入输出、模型档位、提示版本、采样参数。 */
export interface ReplayPackage {
schemaVersion: 1;
runId: string;
workflow: string;
workflowVersion: string;
request: ReplayRequest;
modelTier: ModelTier;
promptVersion: string;
sampling: ReplaySampling;
nodes: readonly ReplayNode[];
outcome: ReplayOutcome;
costMicros: number;
latencyMs: number;
}
export type ReplayDifferenceCode =
| "request"
| "workflow-version"
| "model-tier"
| "prompt-version"
| "sampling"
| "node-order"
| "node-input"
| "node-output"
| "node-status"
| "outcome"
| "cost"
| "latency";
export interface ReplayDifference {
code: ReplayDifferenceCode;
path: string;
baseline: string;
replayed: string;
}
export interface ReplayComparison {
runId: string;
identical: boolean;
/** 第一个发生输入或输出分歧的节点;包级差异全部一致时才有值。 */
firstDivergenceNode?: string;
differences: ReplayDifference[];
}
const OUTCOMES: readonly ReplayOutcome[] = [
"success",
"partial",
"failure",
"timeout",
"unauthorized",
];
const TIERS: readonly ModelTier[] = ["frontier", "standard", "economy"];
const STATUSES: readonly ReplayNodeStatus[] = ["ok", "failed", "timeout", "skipped", "denied"];
function fail(message: string): never {
throw new AiError("invalid-output", `replay: ${message}`);
}
function text(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim() === "") fail(`${field} 必须是非空字符串`);
return value;
}
function integer(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
fail(`${field} 必须是 >= 0 的整数`);
}
return value;
}
function finite(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
fail(`${field} 必须是 >= 0 的有限数`);
}
return value;
}
/** 节点摘要用 SHA-256 前 16 位十六进制;足以区分内容,又不会把原文写进包。 */
export function digestText(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex").slice(0, 16);
}
/** 递归按键名排序;数组保持原顺序,因为节点顺序本身是回放语义的一部分。 */
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map((item) => canonical(item));
if (value !== null && typeof value === "object") {
const source = value as Record<string, unknown>;
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(source).sort()) {
sorted[key] = canonical(source[key]);
}
return sorted;
}
return value;
}
export function validateReplay(pkg: ReplayPackage): void {
if (pkg.schemaVersion !== 1) fail(`不支持的 schemaVersion ${String(pkg.schemaVersion)}`);
text(pkg.runId, "runId");
text(pkg.workflow, "workflow");
text(pkg.workflowVersion, "workflowVersion");
text(pkg.request.capability, "request.capability");
text(pkg.request.tenantId, "request.tenantId");
text(pkg.request.input, "request.input");
integer(pkg.request.deadlineMs, "request.deadlineMs");
if (!TIERS.includes(pkg.modelTier)) fail(`未知的 modelTier ${String(pkg.modelTier)}`);
text(pkg.promptVersion, "promptVersion");
finite(pkg.sampling.temperature, "sampling.temperature");
if (pkg.sampling.temperature > 2) fail("sampling.temperature 必须落在 [0, 2]");
integer(pkg.sampling.seed, "sampling.seed");
integer(pkg.sampling.maxOutputTokens, "sampling.maxOutputTokens");
if (pkg.nodes.length === 0) fail("回放包至少需要一个节点");
const seen = new Set<string>();
for (const node of pkg.nodes) {
text(node.nodeId, "node.nodeId");
if (seen.has(node.nodeId)) fail(`重复的 nodeId ${node.nodeId}`);
seen.add(node.nodeId);
text(node.inputDigest, `${node.nodeId}.inputDigest`);
text(node.outputDigest, `${node.nodeId}.outputDigest`);
if (!STATUSES.includes(node.status)) fail(`未知的节点状态 ${String(node.status)}`);
finite(node.latencyMs, `${node.nodeId}.latencyMs`);
}
if (!OUTCOMES.includes(pkg.outcome)) fail(`未知的 outcome ${String(pkg.outcome)}`);
integer(pkg.costMicros, "costMicros");
integer(pkg.latencyMs, "latencyMs");
}
/** 序列化是规范化的:同样的包永远得到同样的字节,便于做快照与差异比较。 */
export function serializeReplay(pkg: ReplayPackage): string {
validateReplay(pkg);
return `${JSON.stringify(canonical(pkg))}\n`;
}
export function deserializeReplay(serialized: string): ReplayPackage {
let raw: unknown;
try {
raw = JSON.parse(serialized);
} catch (error) {
throw new AiError("invalid-output", "replay: 回放包不是合法 JSON", { cause: error });
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
fail("回放包必须是 JSON 对象");
}
const pkg = raw as ReplayPackage;
validateReplay(pkg);
return pkg;
}
function sameJson(left: unknown, right: unknown): boolean {
return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
}
/**
* 对比基线包与重放包。节点级分歧优先记录,firstDivergenceNode 指向第一个
* 输入或输出摘要变化的节点,用来回答“从哪一步开始不一样”。
*/
export function compareReplay(baseline: ReplayPackage, replayed: ReplayPackage): ReplayComparison {
validateReplay(baseline);
validateReplay(replayed);
const differences: ReplayDifference[] = [];
let firstDivergenceNode: string | undefined;
const push = (code: ReplayDifferenceCode, path: string, before: unknown, after: unknown): void => {
differences.push({
code,
path,
baseline: typeof before === "string" ? before : JSON.stringify(before),
replayed: typeof after === "string" ? after : JSON.stringify(after),
});
};
if (!sameJson(baseline.request, replayed.request)) push("request", "request", baseline.request, replayed.request);
if (baseline.workflowVersion !== replayed.workflowVersion) {
push("workflow-version", "workflowVersion", baseline.workflowVersion, replayed.workflowVersion);
}
if (baseline.modelTier !== replayed.modelTier) {
push("model-tier", "modelTier", baseline.modelTier, replayed.modelTier);
}
if (baseline.promptVersion !== replayed.promptVersion) {
push("prompt-version", "promptVersion", baseline.promptVersion, replayed.promptVersion);
}
if (!sameJson(baseline.sampling, replayed.sampling)) {
push("sampling", "sampling", baseline.sampling, replayed.sampling);
}
const nodeCount = Math.min(baseline.nodes.length, replayed.nodes.length);
for (let index = 0; index < nodeCount; index += 1) {
const before = baseline.nodes[index];
const after = replayed.nodes[index];
if (before.nodeId !== after.nodeId) {
push("node-order", `nodes[${index}].nodeId`, before.nodeId, after.nodeId);
if (firstDivergenceNode === undefined) firstDivergenceNode = before.nodeId;
continue;
}
if (before.inputDigest !== after.inputDigest) {
push("node-input", `nodes[${index}].inputDigest`, before.inputDigest, after.inputDigest);
if (firstDivergenceNode === undefined) firstDivergenceNode = before.nodeId;
}
if (before.outputDigest !== after.outputDigest) {
push("node-output", `nodes[${index}].outputDigest`, before.outputDigest, after.outputDigest);
if (firstDivergenceNode === undefined) firstDivergenceNode = before.nodeId;
}
if (before.status !== after.status) {
push("node-status", `nodes[${index}].status`, before.status, after.status);
if (firstDivergenceNode === undefined) firstDivergenceNode = before.nodeId;
}
}
if (baseline.nodes.length !== replayed.nodes.length) {
push("node-order", "nodes.length", baseline.nodes.length, replayed.nodes.length);
}
if (baseline.outcome !== replayed.outcome) push("outcome", "outcome", baseline.outcome, replayed.outcome);
if (baseline.costMicros !== replayed.costMicros) {
push("cost", "costMicros", baseline.costMicros, replayed.costMicros);
}
if (baseline.latencyMs !== replayed.latencyMs) {
push("latency", "latencyMs", baseline.latencyMs, replayed.latencyMs);
}
return {
runId: baseline.runId,
identical: differences.length === 0,
firstDivergenceNode,
differences,
};
}
compareReplay 先比包级字段,再按节点顺序比输入摘要、输出摘要与状态。firstDivergenceNode 只在节点级出现分歧时才有值,它回答的是「从哪一步开始不一样」,而不是「哪个字段不一样」。
一次重放定位
基线包记录提示 v3、standard 档、温度 0、种子 424242,结果是成功,成本 3,786 微元、延迟 1,269ms。有人把提示升到 v4 后,同一条请求失败,成本 1,842 微元。
把两次执行分别序列化后对比,差异有四项:promptVersion 从 v3 变成 v4,answer 节点的输出摘要变化,outcome 从 success 变成 failure,costMicros 变化。firstDivergenceNode 是 answer,它之前的 retrieve 输入输出完全一致。
结论很直接:分歧不是检索造成的,也不是模型档位或种子造成的,而是提示改版。把 promptVersion 改回 v3、其余字段不变,重放包与基线逐字节相同,对比结果 identical: true。这一步不是「相信直觉」,而是用包把变量逐个排除。
三种重放结论
第一种,完全相同。包的输入、档位、提示、种子和节点输出都一致,说明这次失败没有被包捕获:可能是外部系统状态、并发竞争或包外的人工节点。此时应该扩包,而不是继续重跑。
第二种,输入相同、输出不同。档位、提示、种子都一致,某节点输出摘要却变了,说明模型侧发生了漂移:提供方更新了模型、温度没有真正生效,或者采样参数被网关覆盖。修复动作是版本锁定与参数透传检查。
第三种,失败节点之前的输入摘要就变了。说明上游产出在两次执行间不同,包没有冻结上游状态;常见于检索索引更新、缓存过期或人工节点。此时要先补上游快照,再看失败节点。
保留窗口与隐私
回放包按 7 天保留请求原文与节点摘要,30 天保留包级结果与摘要;完整节点输出默认不存,只对抽样失败开启短期留存。租户级删除请求要能级联删除回放包,否则调试系统会变成隐私死角。
回放包不是审计日志。审计要记录「谁在什么时候做了什么」,回放要记录「这次执行为什么走这条路径」。两者的字段有重叠,但保留策略与访问权限应该分开。
小结
不可复现通常不是模型不可解释,而是复现时没有冻结变量。最小回放包记录请求、节点输入输出摘要、模型档位、提示版本、采样参数与结果;规范化序列化保证同包同字节;逐节点对比用 firstDivergenceNode 把分歧定位到具体节点。示例中提示 v3 升到 v4,分歧落在 answer,改回 v3 后重放包与基线完全一致。
7.1 定义任务级验收,7.2 把失败拆到节点,7.3 让同一条失败可以被重放和排除。这三件事合起来,编排质量才从「看板上的一个百分比」变成可复核的证据。
阅读导航:上一节:7.2 失败归因:哪个节点造成的 · 下一节:8.1 把预算分到节点 。
常见错误与边界
- 只记录最终错误。没有节点输入输出摘要,无法判断分歧从哪一步开始。
- 回放时用当前提示与当前档位。环境没有冻结,重跑得到的是另一个任务。
- 不记录随机种子。温度为 0 也不代表所有提供方都确定,种子仍要显式保存。
- 把完整节点输出都存下来。体积与隐私风险都会失控,摘要加受控原文更合适。
- 序列化不排序键。语义相同的包会因为键顺序不同而比较失败。
- 把
identical: true当成「不是我们的问题」。它也可能说明包没有捕获外部状态。 - 回放包无限期保留。请求原文与节点摘要都应按窗口清理,并支持租户级删除。
- 用回放包替代审计日志。两者目的、字段与访问权限不同,不能混用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。