AI 事故无堆栈跟踪,调查完全依赖日志;文章给出 RunRecord 数据模型设计,含 toolCalls、版本字段、上下文 Token 等关键追溯维度。
AI 事故不像普通事故那样。没有堆栈跟踪,没有健康检查失败。只有客户说 Agent 告诉他们了某些假消息,或者审计日志显示一小时内有四十笔退款而实际应该只有四笔。
调查完全取决于事故发生前你恰好记录了什么。没有什么是可复现的——同样的输入,不同的运行,所以Trace就是证据,如果没有被捕获,它就不存在。
这条记录必须足以重构决策
export type RunRecord = {
runId: string;
userId: string;
startedAt: string;
finishedAt: string;
// what produced this output
model: string;
promptVersion: string;
toolsetVersion: string;
temperature: number;
// what the agent saw
input: string;
retrievedDocIds: string[]; // ids, not bodies
contextTokens: number;
// what it did
toolCalls: { name: string; argsHash: string; ok: boolean; ms: number }[];
turns: number;
outcome: Outcome;
costUsd: number;
// who allowed it
approvals: { by: string; at: string; verdict: string }[];
capabilities: string[];
};
这四个版本字段把"上周二质量下降了"从一场争论变成了一次查询。retrievedDocIds 让你可以查 Agent 是否在读一份被污染的文档——这是 Agent 对一个租户表现异常而对其他所有人都正常的最常见原因。
capabilities 回答的是"它究竟是怎么做到这一点的",这通常是"它做了什么"之后的第二个问题。
将完整 Trace 单独存储,生命周期更短
上面的记录足够小,可以保存一年。完整 transcript 不是,而且它也是包含客户数据的部分。
await traceStore.put(runId, {
messages: redact(messages),
toolResults: toolResults.map((r) => ({ ...r, body: truncate(r.body, 4_000) })),
}, { ttlDays: 30 });
三十天覆盖了几乎所有调查场景。再长,就是在持有你没有产品理由持有的客户对话。
在写入时做脱敏,而不是在读取时:
const PATTERNS = [
[/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, "[email]"],
[/\b(?:\d[ -]*?){13,19}\b/g, "[card]"],
[/\b(sk|pk|ghp|xox[baprs])[-_][A-Za-z0-9]{16,}\b/g, "[secret]"],
] as const;
不完美,但比什么都不做强。任何真正敏感的数据都应该通过 id 引用,永远不要一开始就进入 transcript。

一个 id,贯穿所有地方
res.setHeader("X-Run-Id", runId);
logger.info("agent", { runId, ... });
await db.refund.create({ data: { ..., runId } });
await mailer.send({ ..., headers: { "X-Run-Id": runId } });
把它放在效果上——退款行、已发送的邮件——的原因是事故是从效果开始的。有人发现了一笔错误的退款。如果那张表上没有 runId 列,把它连接到一个 run 就是跨两个系统的时间戳匹配练习。
最初十分钟
先遏制,再诊断。你的上线 flag 应该还在:
await flags.update("agent-v1", { mode: "suggest" });
降一档让人在输出前把关,同时不拿走功能。如果效果是不可逆的且正在发生,就关掉。
找出爆炸半径。
SELECT run_id, user_id, outcome, cost_usd
FROM run_records
WHERE started_at > now() - interval '6 hours'
AND tool_calls @> '[{"name": "refund_order"}]'
ORDER BY started_at;
先回答"有多少",再回答"为什么",这让你在半小时内就能告诉支持团队一些真实的情况。
冻结证据。Trace 有 TTL,而超过 TTL 的调查会失去自己的材料:
await traceStore.hold(runIds, { until: addDays(new Date(), 180) });
早点做这一步。这是到了第 29 天最常被想起的一步。
从检查点复现,而不是从输入
重新运行输入会给你一个不同的 run,什么也告诉不了你。重放存储的状态是最接近复现的方式:
const history = [];
for await (const s of graph.getStateHistory({ configurable: { thread_id: runId } })) {
history.push(s);
}
const before = history.find((s) => s.next.includes("refund_order"));
await graph.invoke(null, before.config); // with side effects disabled
禁用副作用——一个 dry-run 工具包装器,因为否则调查一个错误的退款会发出更多错误的退款。
改一个值来叉出去测试一个假设。这就是你区分"模型误读了正确数据"和"状态中的数据本来就是错的"的方式,而这两种情况需要完全不同的修复。
值得优先检查的三个原因
注入的指令。拉取这个 run 的检索文档然后读它们。内容由从 Agent 行为中受益的人写的,这是典型案例,而 retrievedDocIds 让它成为一个两分钟的检查。
一个静默的上游变更。将 model 和 promptVersion 与事件窗口之前的 run 进行比较。提供商一侧的变更无需部署就会生效。
一个过宽的能力。看 capabilities 并问这个行为是否本来就不应该可能。 通常诚实的发现是:模型给定它不应该拥有的工具时行为是合理的。

值得写的复盘
站得住脚的行动项不是"改进 prompt"。Prompt 不是控制手段——它们影响行为,但不约束行为。
站得住脚的那些看起来像:
最后一条是复合项。每次事故都会揭示一个你希望自己有的字段;加入它让下次调查花一个小时而不是一天。
AI That Ships 覆盖了在生产环境中运行 AI 功能——Trace 和审计设计、脱敏和保留、遏制、基于回放的调查,以及真正防止复发的后续行动。
