智能体那些「偶发」故障,多半不是某一句提示词写得不够好,而是工具并发、重试、超时和取消在时间轴上交错出了你从没点过的顺序。手工点对话既控不住时钟,也复现不了同一条调度路径。本文用 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 里留下一套不依赖线上模型的测试床。聊天截图适合当天排查,回归用例才适合半年后的自己。