智能体那些「偶发」故障,多半不是某一句提示词写得不够好,而是工具并发、重试、超时和取消在时间轴上交错出了你从没点过的顺序。手工点对话既控不住时钟,也复现不了同一条调度路径。本文用 Node.js 搭虚拟时钟、可注入故障的假工具,以及可回放的调度记录,把这类竞态收成不依赖线上模型的回归用例。
手工点对话,为什么偏偏测不到竞态
线上智能体最难缠的问题,很少表现为「模型完全不会用工具」。更常见的是另一类:两个工具几乎同时返回,状态机先写下 A,再被 B 的超时重试覆盖;用户点了停止,已经在飞的请求稍后成功,把已取消的会话又写脏;超时触发的那一瞬间,退避定时器也到点,于是同一笔副作用提交了两次。
这类故障的共同特征是对交错顺序敏感。你在聊天窗口里手工点,模型每次选出的工具可能不同,真实网络延迟是随机的,取消也没法精确卡在第 47 毫秒。截图能证明「今天下午坏过」,却不能证明「这条调度路径已经锁死」。半年后模型换了、网关换了,同一张截图几乎没有回归价值。
所以测试对象要换。不要测「这句话答得漂不漂亮」,要测运行时在并发、重试、超时、取消下是否仍满足几条硬不变量:取消之后不再提交副作用;同一调用至多采纳一次成功结果;迟到的响应不能写进状态。提示词负责决策质量,运行时负责这些契约。两者混在一次手工对话里,失败了你甚至分不清该改哪一层。
先换镜头:智能体是调度器
把智能体拆开,至少有三层。模型在某一步选出工具调用或最终回复;工具执行副作用;运行时并行拉起工具、给每次调用套超时、失败后退避重试,并把取消信号传下去。提示词调的是第一层,顽固竞态多半在第三层。
确定性仿真的做法很克制:模型改成脚本,按步返回预先写好的决策;工具改成假实现,延迟、失败次数、是否挂起都可以注入;时间改成虚拟时钟,测试里用 advance 推进。一次测试对应一条调度路径,而不是一次不可复现的聊天。
下面的代码只用 Node.js 18 起自带的 node:test、AbortSignal 和 Promise,不调用任何线上模型接口。它验证的是调度契约,不是模型智力。真实 HTTP 连接池、鉴权头、供应商限流,仍然需要少量集成测试;仿真床不替代那些,只把过去只能靠手工复现的交错顺序先锁住。
虚拟时钟和可注入故障的假工具
虚拟时钟只做两件事:给出单调时间,以及让 sleep 在 advance 时按到期顺序唤醒。取消走标准的 AbortSignal,这样「用户点停止」和「单次工具超时」能走同一条中止路径,而不是两套各写各的 clearTimeout。
假工具不要做成永远成功的替身。至少要能表达三种故障:延迟到达、前 N 次失败、一直挂起直到被取消。副作用单独记账,测试才能区分「尝试过几次」和「真正提交了几次」。这两者在重试场景里本来就不是一回事,用同一个计数器会把 bug 藏起来。
先让时间听指挥
把下面存成 clock-demo.mjs,用 node clock-demo.mjs 运行。它在虚拟 20ms 处取消一个 5 秒睡眠,真实墙钟几乎不走。
class VirtualClock {
constructor() {
this._now = 0;
this._seq = 0;
this._timers = [];
}
now() {
return this._now;
}
sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
const err = new Error('This operation was aborted');
err.name = 'AbortError';
reject(err);
return;
}
const timer = { id: ++this._seq, at: this._now + ms, resolve, reject };
this._timers.push(timer);
this._timers.sort((a, b) => a.at - b.at || a.id - b.id);
signal?.addEventListener(
'abort',
() => {
this._timers = this._timers.filter((item) => item.id !== timer.id);
const err = new Error('This operation was aborted');
err.name = 'AbortError';
reject(err);
},
{ once: true },
);
});
}
async advance(ms) {
const target = this._now + ms;
while (this._timers.length > 0 && this._timers[0].at <= target) {
const timer = this._timers.shift();
this._now = timer.at;
timer.resolve();
await Promise.resolve();
}
this._now = target;
}
}
const clock = new VirtualClock();
const ac = new AbortController();
const hung = clock.sleep(5000, ac.signal).then(
() => console.log('should not run'),
(err) => console.log(err.name, 'at', clock.now()),
);
await clock.advance(20);
ac.abort();
await hung;
advance 里每次唤醒后 await Promise.resolve(),是为了让已到期的 sleep 后面那段异步逻辑有机会再登记新的定时器。没有这一步,退避重试很容易被时钟一步跨过。虚拟时间只能推进,不能倒放;要换一条交错路径,就另写一个测试,从 t=0 重新开跑。
可回放的调度记录
有了时钟和假工具,还缺一份按虚拟时间戳记下的事件:模型决策、工具开始、成功或失败、超时、重试、取消。测试断言的是这份记录,而不是某一句自然语言。记录只保留稳定字段:t、type、tool、attempt。不要把模型原文塞进快照,否则你又回到了不可复现的聊天日志。
下面是一份单文件测试,保存为 agent-sim.test.mjs 后执行 node --test agent-sim.test.mjs。为了可单独跑,时钟实现内联在文件里。
import { test } from 'node:test';
import assert from 'node:assert/strict';
function makeAbortError() {
const err = new Error('This operation was aborted');
err.name = 'AbortError';
return err;
}
class VirtualClock {
constructor() {
this._now = 0;
this._seq = 0;
this._timers = [];
}
now() {
return this._now;
}
sleep(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(makeAbortError());
return;
}
const timer = { id: ++this._seq, at: this._now + ms, resolve, reject };
this._timers.push(timer);
this._timers.sort((a, b) => a.at - b.at || a.id - b.id);
signal?.addEventListener(
'abort',
() => {
this._timers = this._timers.filter((item) => item.id !== timer.id);
reject(makeAbortError());
},
{ once: true },
);
});
}
async advance(ms) {
const target = this._now + ms;
while (this._timers.length > 0 && this._timers[0].at <= target) {
const timer = this._timers.shift();
this._now = timer.at;
timer.resolve();
await Promise.resolve();
}
this._now = target;
}
}
function createLog() {
const events = [];
return {
events,
record(clock, type, extra = {}) {
events.push({ t: clock.now(), type, ...extra });
},
};
}
function createTools(clock) {
const debitFails = { count: 0 };
return {
debit: async (args, signal) => {
await clock.sleep(args.delay, signal);
if (debitFails.count < (args.failTimes ?? 0)) {
debitFails.count += 1;
throw new Error('debit failed');
}
return { ok: true, amount: args.amount };
},
audit: async (_args, signal) => {
await clock.sleep(60_000, signal);
return { ok: true };
},
};
}
async function callWithTimeoutAndRetry(opts) {
const { name, fn, clock, signal, log, timeoutMs, retries, backoffMs, commits } = opts;
let lastError;
for (let attempt = 1; attempt <= retries + 1; attempt++) {
if (signal.aborted) throw makeAbortError();
log.record(clock, 'tool_start', { tool: name, attempt });
const nested = new AbortController();
const onParent = () => nested.abort();
signal.addEventListener('abort', onParent, { once: true });
let timeoutHit = false;
const timeoutWait = clock.sleep(timeoutMs, nested.signal).then(
() => {
timeoutHit = true;
nested.abort();
},
() => {},
);
try {
const result = await fn(nested.signal);
nested.abort();
await timeoutWait;
signal.removeEventListener('abort', onParent);
log.record(clock, 'tool_ok', { tool: name, attempt });
commits.push({ tool: name, result });
return result;
} catch (err) {
nested.abort();
await timeoutWait;
signal.removeEventListener('abort', onParent);
if (signal.aborted) {
log.record(clock, 'cancelled', { tool: name, attempt });
throw makeAbortError();
}
if (timeoutHit) {
log.record(clock, 'timeout', { tool: name, attempt });
lastError = new Error('Timeout');
lastError.name = 'TimeoutError';
} else if (err.name === 'AbortError') {
log.record(clock, 'cancelled', { tool: name, attempt });
throw err;
} else {
log.record(clock, 'tool_err', { tool: name, attempt, message: err.message });
lastError = err;
}
if (attempt > retries) break;
await clock.sleep(backoffMs, signal);
}
}
throw lastError;
}
async function runAgent(options) {
const { model, tools, clock, signal, log, timeoutMs, retries, backoffMs, commits } = options;
const transcript = [];
for (let step = 0; step < 6; step++) {
if (signal.aborted) throw makeAbortError();
const decision = model(transcript, step);
log.record(clock, 'model', { step, kind: decision.type });
if (decision.type === 'final') {
return { text: decision.text, commits };
}
const results = await Promise.all(
decision.calls.map((call) =>
callWithTimeoutAndRetry({
name: call.name,
fn: (sig) => tools[call.name](call.args, sig),
clock,
signal,
log,
timeoutMs,
retries,
backoffMs,
commits,
}),
),
);
transcript.push({ step, results });
}
throw new Error('max steps exceeded');
}
test('取消后迟到的工具成功不得提交副作用', async () => {
const clock = new VirtualClock();
const log = createLog();
const commits = [];
const controller = new AbortController();
const tools = createTools(clock);
const model = (_transcript, step) => {
if (step === 0) {
return {
type: 'tools',
calls: [
{ name: 'debit', args: { delay: 100, amount: 30 } },
{ name: 'audit', args: {} },
],
};
}
return { type: 'final', text: 'done' };
};
const running = runAgent({
model,
tools,
clock,
signal: controller.signal,
log,
timeoutMs: 1000,
retries: 0,
backoffMs: 20,
commits,
}).catch((err) => err);
await clock.advance(50);
controller.abort();
await clock.advance(200);
const err = await running;
assert.equal(err.name, 'AbortError');
assert.equal(commits.length, 0);
assert.equal(log.events.filter((e) => e.type === 'tool_ok').length, 0);
assert.ok(log.events.some((e) => e.type === 'cancelled' && e.tool === 'debit'));
});
test('失败重试只提交一次副作用', async () => {
const clock = new VirtualClock();
const log = createLog();
const commits = [];
const controller = new AbortController();
const tools = createTools(clock);
const model = (_transcript, step) => {
if (step === 0) {
return {
type: 'tools',
calls: [{ name: 'debit', args: { delay: 10, failTimes: 2, amount: 7 } }],
};
}
return { type: 'final', text: 'done' };
};
const running = runAgent({
model,
tools,
clock,
signal: controller.signal,
log,
timeoutMs: 100,
retries: 2,
backoffMs: 20,
commits,
});
await clock.advance(100);
const result = await running;
assert.equal(result.text, 'done');
assert.equal(commits.length, 1);
assert.deepEqual(commits[0].result, { ok: true, amount: 7 });
assert.deepEqual(
log.events.map((e) => ({ t: e.t, type: e.type, attempt: e.attempt })),
[
{ t: 0, type: 'model', attempt: undefined },
{ t: 0, type: 'tool_start', attempt: 1 },
{ t: 10, type: 'tool_err', attempt: 1 },
{ t: 30, type: 'tool_start', attempt: 2 },
{ t: 40, type: 'tool_err', attempt: 2 },
{ t: 60, type: 'tool_start', attempt: 3 },
{ t: 70, type: 'tool_ok', attempt: 3 },
{ t: 70, type: 'model', attempt: undefined },
],
);
});
第一条路径锁的是取消:用户在 50ms 停止,100ms 才返回的 debit 不得记入 commits,挂起的 audit 也必须被同一条信号打断。第二条锁的是重试:工具前两次失败、第三次成功,尝试三次,提交一次。两条都不读线上模型,跑多少遍都是同一份调度记录。时间表是测试的一部分,不要在运行时里偷偷 Date.now(),否则记录立刻失去确定性。
把仿真收成回归用例
落地时我通常把测试床收成三件事。第一,脚本化模型:用纯函数按 step 返回 final 或一组 calls,专门覆盖「并行两个工具」「失败后不再调用」这类运行时分支,而不是覆盖「模型会不会选对工具」。第二,故障清单:每个假工具声明延迟、失败次数、是否挂起,再加一个共享的 commits 数组。第三,时间表:测试里显式 await clock.advance(...),需要取消就在某个时刻 controller.abort()。CI 里只跑这一类文件,不要在同一套件里再打真实模型,以免把非确定性重新引进来。
不变量尽量写少、写硬。取消后 commits 仍为空;超时路径上必须出现 timeout,且不得出现迟到的 tool_ok 提交;重试路径上 attempt 递增、提交只有一次。这些断言半年后还成立,因为它们不依赖模型版本,也不依赖公网延迟。调度记录一旦稳定,就可以当快照用;一旦你改了重试次数或超时预算,快照理应失败,那是提醒你契约变了,不是测试脆。
这套方法明确测不到的东西也说清楚:它不证明模型会选对工具,也不证明真实客户端的 DNS、TLS、连接复用。那些仍要靠少量集成测试和线上可观测性。仿真测试的价值更窄,也更值得重复跑——把过去只能靠手工点对话才偶尔碰到的交错顺序,变成每次提交都会执行的回归。发现新的线上竞态时,先用虚拟时钟把那条时间表写下来,再修运行时;不要先改提示词碰运气。
总结
智能体的顽固故障,优先从工具并发、重试、超时和取消的交错顺序查起,而不是先改提示词。用虚拟时钟卡住时间,用可注入故障的假工具卡住 I/O,用可回放的调度记录卡住顺序,就能在 Node.js 里留下一套不依赖线上模型的测试床。聊天截图适合当天排查,回归用例才适合半年后的自己。
评论