消息失败与工具失败是两种本质不同的错误:前者需进入DLQ重试,后者模型通常可自行恢复;建议将工具故障独立处理,只在恢复失效时才送入死信队列。
两种看起来相同的失败
消息失败是工作项本身的缺陷。负载无法反序列化、引用了已删除的实体,或遇到一个每次重试都会复现的消费端 bug。重试毫无意义;消息在达到重投递上限后进入 DLQ,由人工介入查看。这些与 Agent 无关,是已经被充分讨论过的领域。
工具失败是 Agent 所操作的世界中的缺陷。运行是有效的,模型做出了合理的选择,但工具返回了 500、超时、触发了自身的速率限制,或因为下游记录被锁定而拒绝。关键区别在于模型通常可以处理它:被告知工具失败后,它可以重试、使用其他工具,或在无法继续时完成。这使得第一种响应方式——将工具结果标记为错误并返回循环中——变得可行,就像处理循环中工具超时一样。
只有当带内恢复彻底失败后,它才变成队列问题,而且即使变成队列问题,也是不同的问题:运行已经部分完成,已经付出了昂贵的代价,有意义的操作是从失败的步骤恢复而不是重新开始。共享的 DLQ 会丢失这种区别——消费者无法区分"从头重放这条消息"和"重试这个运行的第 4 步",格式错误负载的值班表和支付 API 宕机的值班表也不是同一份。
明确说明这一点,因为歧义才是产生共享队列的根源:
无法解析或验证消息 → message DLQ,立即,不重试。它永远不会成功。
工具暂时性失败,还有预算 → 完全不是队列事件。错误工具结果,模型获得下一次机会。
工具失败且带内恢复已耗尽 → tool DLQ,携带运行状态。
工具失败因为每次运行都配置错误——认证被拒绝,端点消失 → tool DLQ,并打开断路器以免接下来一千次运行各自单独发现它。参见断路器。
消费者在运行中崩溃 → 两者都不是。消息被重新投递,运行恢复;这种情况需要的是幂等性,而不是 DLQ。
第四种和第五种是实现最容易出错的地方。配置错误的工具在几分钟内将每次运行都发送到 DLQ,这在技术上是正确的路由,但在操作上是洪水泛滥——断路器将一万条 DLQ 条目变成一次告警。而路由到 DLQ 的消费者崩溃会丢失那些在重新投递后本来可以正常完成的运行。
消息 DLQ 条目是原始消息。工具 DLQ 条目不能是原始消息,因为原始消息已无法描述状态:运行已经进行到一半。它必须携带足够的信息来恢复。
type ToolFailureEntry = {
runId: string;
stepIndex: number; // which turn of the loop failed
toolName: string;
toolArgs: unknown; // redacted per the same rules as your logs
attempts: number; // in-band retries already spent
lastError: { kind: string; status: number | null; message: string };
conversationRef: string; // pointer to stored messages, not the messages
tokensSpent: { input: number; output: number };
firstSeenAt: string;
originalMessageId: string;
};
两个字段承载了大部分价值。conversationRef 是一个指针而不是对话本身:消息历史很大,而且经常包含用户内容,而 DLQ 是一个东西会堆积数周的地方,其访问控制比主存储更宽松。而 tokensSpent 正是使重放决策变得理性的东西——恢复一个已经消耗了 40,000 tokens 的运行比重新启动它更有价值,没有这个数字,没人知道他们选择的是哪一个。
三种情况,每个目的地一个,断言的是路由而不是失败。将两个队列都伪造为数组;被测试的东西是哪一个接收到条目。
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleMessage } from "../src/worker";
let messageDlq: any[];
let toolDlq: any[];
let queues: { messageDlq: typeof messageDlq; toolDlq: typeof toolDlq };
beforeEach(() => {
messageDlq = [];
toolDlq = [];
queues = { messageDlq, toolDlq };
});
describe("dead-letter routing", () => {
it("sends a malformed message to the message DLQ and never starts a run", async () => {
const model = vi.fn();
await handleMessage({ body: "{not json" }, { model, tools: {}, queues });
expect(messageDlq).toHaveLength(1);
expect(toolDlq).toHaveLength(0);
expect(model).not.toHaveBeenCalled(); // no tokens spent on a broken payload
});
it("keeps a transient tool failure in-band and out of both queues", async () => {
const lookup = vi.fn()
.mockRejectedValueOnce(new ToolError({ kind: "server", status: 503 }))
.mockResolvedValueOnce({ order: 9 });
const result = await handleMessage(validMessage, {
model: modelCallingLookupThenAnswering(),
tools: { lookup }, queues, toolRetryBudget: 2,
});
expect(lookup).toHaveBeenCalledTimes(2);
expect(result.status).toBe("completed");
expect(messageDlq).toHaveLength(0);
expect(toolDlq).toHaveLength(0);
});
it("sends an exhausted tool failure to the tool DLQ with resumable state", async () => {
const lookup = vi.fn().mockRejectedValue(new ToolError({ kind: "server", status: 503 }));
const result = await handleMessage(validMessage, {
model: modelAlwaysCallingLookup(), tools: { lookup }, queues, toolRetryBudget: 2,
});
expect(messageDlq).toHaveLength(0);
expect(toolDlq).toHaveLength(1);
const entry = toolDlq[0];
expect(entry.toolName).toBe("lookup");
expect(entry.attempts).toBe(3);
expect(entry.stepIndex).toBeGreaterThan(0);
expect(entry.conversationRef).toMatch(/^conv_/);
expect(entry.tokensSpent.input).toBeGreaterThan(0);
expect(JSON.stringify(entry)).not.toContain(CUSTOMER_EMAIL); // redaction applies here too
expect(result.status).toBe("tool_failed");
});
});
负面断言和正面断言同样重要。每个用例都断言它不应该达到的队列,因为被测试的失败模式是路由错误而不是丢失——错误队列中的条目并不是消失了,而是被错误的进程处理了,只有交叉断言才能捕捉到这一点。脱敏检查也属于这里:DLQ 负载是由一个通常会跳过你的日志记录器应用的任何清理措施的路径写入的。
没有人能消费的 DLQ 是一条昂贵的日志。重放路径需要自己的测试,而它们是最常被遗漏的。
从步骤恢复,而不是从头开始。用一个现在能正常工作的工具重放条目,并断言模型只被调用了剩余的步骤——计算调用次数。这才证明了 conversationRef 实际被使用而不是被悄悄忽略以支持全新运行。
重放是幂等的。两次重放同一条目,并断言任何副作用只发生一次,以 run id 和 step index 为键。消费一个 DLQ 恰恰是在有人两次运行脚本的时候。
永久损坏的运行被干净地丢弃。断言其对话引用已过期的条目以可区分的状态失败而不是抛出异常,这样消费一千条条目不会在第一个坏的条目上停止。
队列是有界限且被观察的。断言指标递增且条目携带 firstSeenAt,这样"最旧条目年龄"是可告警的。没有这个指标的 DLQ 在下一次事故中被发现。
值得在测试旁边记录的一个设计说明:如果某个工具的失败确实很常见,DLQ 是错误的目的地,Agent 应该有一个有文档记录的 fallback——不同的工具、降级的答案、转交。DLQ 是为那些你没有计划到的失败准备的,它的条目数量是一个很好的信号,可以看出你应该计划哪些失败。当一个工具的失败影响所有人而不是单次运行时,答案在上游——参见诊断不触发的工具调用。