智能体把「今天、本周、三天后」写进提示词,测试就冻不住时间,换时区就答错,检索窗口也会跟着漂。本文用 Node.js 把时钟做成可注入依赖,统一解析相对时间,给检索和工具打上 as-of,并在输出侧核对模型写出的日期。目标不是消灭幻觉,而是把系统时间从提示词里解耦,让时间行为可单测、可复现。

问题不在模型,在「今天」是活的

智能体应用里,时间通常以三种面目同时出现:提示词里的自然语言(「今天」「本周」)、工具参数里的时间窗(start / end)、以及模型最终写进回答的具体日期。这三处如果各自去读 new Date(),系统就会在三个时钟上运行。

后果很具体。单测无法冻结「今天」,同一条用例昨天过、今天挂。服务跑在 UTC、用户在 Asia/Shanghai,周一凌晨的「本周」会差出一整天。检索「最近三天的工单」时,若索引按服务器本地日历切,召回集合会随部署时区漂移。更糟的是模型会在回答里随手写出一个看起来合理的日期,日志里却对不上任何一次工具调用。

常见权宜之计是在提示词开头写「今天是 2026-03-22」。这能减少一部分口误,但没有解决依赖方向:时间仍然是一段不可测试的散文,工具层照样自己读系统钟。Date.now 被改写之后,Intl 时区换算、日志时间戳、第三方 SDK 仍可能走另一条路径。对智能体这种「提示词 + 检索 + 工具 + 生成」的链路,更稳妥的做法是:只有一处产生「现在」,其余全部注入。

下面的实现只用 Node.js 内置的 Date 与 Intl.DateTimeFormat,不引入第三方日期库,也不依赖尚未在所有 LTS 上默认可用的 Temporal。

把时钟做成可注入依赖

先约定一个极窄的接口:时钟只负责回答「此刻是哪一个绝对时刻」。时区、日历、相对时间都不放进时钟里,避免 now() 有时返回 Date、有时返回「上海的今天」这种混型。

// clock.mjs
export function createSystemClock() {
  return {
    now() {
      return new Date();
    },
  };
}

export function createFixedClock(isoString) {
  const frozen = new Date(isoString);
  if (Number.isNaN(frozen.getTime())) {
    throw new Error(`Invalid frozen time: ${isoString}`);
  }
  return {
    now() {
      return new Date(frozen.getTime());
    },
  };
}

export function zonedParts(date, timeZone) {
  const dtf = new Intl.DateTimeFormat("en-US", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    weekday: "short",
    hourCycle: "h23",
  });
  const map = {};
  for (const part of dtf.formatToParts(date)) {
    if (part.type !== "literal") map[part.type] = part.value;
  }
  return {
    year: Number(map.year),
    month: Number(map.month),
    day: Number(map.day),
    hour: Number(map.hour),
    minute: Number(map.minute),
    second: Number(map.second),
    weekday: map.weekday,
  };
}

export function toIsoDate(parts) {
  const y = String(parts.year).padStart(4, "0");
  const m = String(parts.month).padStart(2, "0");
  const d = String(parts.day).padStart(2, "0");
  return `${y}-${m}-${d}`;
}

export function buildTimeContext(clock, timeZone) {
  const now = clock.now();
  const parts = zonedParts(now, timeZone);
  return {
    nowIso: now.toISOString(),
    timeZone,
    localDate: toIsoDate(parts),
    localTime: `${String(parts.hour).padStart(2, "0")}:${String(parts.minute).padStart(2, "0")}:${String(parts.second).padStart(2, "0")}`,
    weekday: parts.weekday,
  };
}

createFixedClock 每次返回新的 Date 实例,避免调用方改到同一对象。zonedParts 用 formatToParts 读取某个 IANA 时区的墙钟,而不是 getHours()——后者是进程本地时区,CI、容器、笔记本三者经常不一致。

提示词不要再手写「今天是……」。调用模型前把 buildTimeContext 的结果当成结构化上下文塞进去,例如:nowIso、timeZone、localDate。模型仍然可能忽略这些字段,但检索和工具不再需要猜「今天」指哪一天。

统一解析相对时间与时区

相对时间是智能体最容易写进工具参数的东西,也最不该交给模型现场换算。把词表收窄:能解析就解析成闭区间的民用日期;不能解析就抛错。失败比「猜成下周」更安全。

日历加减不要用本地 Date#setDate,那会混进进程时区。用 UTC 分量当民用日期计算器即可——这里的 UTC 只是算术纸,不表示用户时区。

// relative-time.mjs
import { toIsoDate, zonedParts } from "./clock.mjs";

const WEEKDAY_TO_OFFSET = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };

function addDays(parts, days) {
  const utc = Date.UTC(parts.year, parts.month - 1, parts.day + days);
  const d = new Date(utc);
  return {
    year: d.getUTCFullYear(),
    month: d.getUTCMonth() + 1,
    day: d.getUTCDate(),
  };
}

function startOfWeekMonday(parts) {
  const offset = WEEKDAY_TO_OFFSET[parts.weekday];
  if (offset === undefined) {
    throw new Error(`Unexpected weekday: ${parts.weekday}`);
  }
  const delta = offset === 0 ? -6 : 1 - offset;
  return addDays(parts, delta);
}

export function resolveRelativeRange(expr, clock, timeZone) {
  const today = zonedParts(clock.now(), timeZone);
  const text = String(expr).trim();

  if (text === "今天" || text === "today") {
    const d = toIsoDate(today);
    return { start: d, end: d };
  }
  if (text === "昨天" || text === "yesterday") {
    const d = toIsoDate(addDays(today, -1));
    return { start: d, end: d };
  }
  if (text === "明天" || text === "tomorrow") {
    const d = toIsoDate(addDays(today, 1));
    return { start: d, end: d };
  }
  if (text === "本周" || text === "this week") {
    const start = startOfWeekMonday(today);
    return { start: toIsoDate(start), end: toIsoDate(addDays(start, 6)) };
  }

  const m = text.match(/^(?:(\d+)天后|in (\d+) days)$/);
  if (m) {
    const n = Number(m[1] || m[2]);
    const d = toIsoDate(addDays(today, n));
    return { start: d, end: d };
  }

  throw new Error(`Unsupported relative time: ${text}`);
}

「本周」明确从周一起算。这是产品决策,不是标准答案;欧美产品若以周日为周首,应改 startOfWeekMonday,而不是让模型各自发挥。词表以外的句子(「下下个工作日」「月末前」)不要在这一层用正则硬猜,交给上游澄清,或映射到明确的 YYYY-MM-DD。

给检索和工具打上 as-of

解析结果还不能直接当数据库条件。调用检索或外部 API 时,带上 as-of:这次调用认为的「现在」。日志、重放、评测都靠它对齐。缺了 as-of,你只能知道「当时搜了最近三天」,不知道「当时」是哪一秒。

// as-of.mjs
import { buildTimeContext } from "./clock.mjs";
import { resolveRelativeRange } from "./relative-time.mjs";

export function buildSearchQuery(expr, clock, timeZone) {
  const ctx = buildTimeContext(clock, timeZone);
  const range = resolveRelativeRange(expr, clock, timeZone);
  return {
    asOf: ctx.nowIso,
    timeZone: ctx.timeZone,
    localDate: ctx.localDate,
    relative: expr,
    range,
  };
}

export function attachAsOf(toolArgs, clock, timeZone) {
  const ctx = buildTimeContext(clock, timeZone);
  return {
    ...toolArgs,
    asOf: ctx.nowIso,
    timeZone: ctx.timeZone,
  };
}

单测不再模拟全局时钟,只注入固定钟:

// clock.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { createFixedClock } from "./clock.mjs";
import { buildSearchQuery } from "./as-of.mjs";

test("本周在上海时区按周一起算", () => {
  const clock = createFixedClock("2026-03-22T16:30:00.000Z");
  const q = buildSearchQuery("本周", clock, "Asia/Shanghai");
  assert.equal(q.localDate, "2026-03-23");
  assert.deepEqual(q.range, { start: "2026-03-23", end: "2026-03-29" });
  assert.equal(q.asOf, "2026-03-22T16:30:00.000Z");
});

2026-03-22T16:30:00.000Z 在上海是 3 月 23 日 00:30,刚好跨过午夜。若测试读进程本地时间,这天在 UTC 仍是周日,在上海已是周一,「本周」会差一整周。这正是要把时区留在解析层、而不是留在提示词里的原因。

跑测试:node --test clock.test.mjs。检索实现应使用 q.range 与 q.asOf,不要在查询函数内部再 new Date()。重放一次失败请求时,用日志里的 asOf 构造 createFixedClock,窗口必须能还原。

输出侧校验模型编造的日期

时间窗对齐之后,模型仍可能在正文里写出窗口外的日期,或把工具返回的 2026-03-23 改成 2026-03-24。不要在生成后再偷偷改数字,那会让用户看到的句子和工具日志对不上。更干净的做法是:抽取、比对、标记。

// validate-dates.mjs
const ISO_DATE = /\b(\d{4}-\d{2}-\d{2})\b/g;

function inRange(day, range) {
  return day >= range.start && day <= range.end;
}

export function validateOutputDates(text, range) {
  const dates = [...text.matchAll(ISO_DATE)].map((m) => m[1]);
  const unexpected = dates.filter((d) => !inRange(d, range));
  return {
    dates,
    unexpected,
    ok: unexpected.length === 0,
  };
}

校验规则要克制。只对「用户问的就是这段时间」的任务启用,例如「列出本周到期的账单」。开放式问答里出现历史日期是正常的,不能见日期就杀。YYYY-MM-DD 之外的写法(「3 月 23 日」)可以后续再加规范化,但不要假装已经做了完整 NLP。

ok === false 时,比较稳妥的产品行为是:把 unexpected 交给一次带结构化上下文的重试,或在回复上标注「正文日期与检索窗口不一致」。自动把日期改掉,短期好看,长期无法审计。

总结

智能体答错日期,多半不是模型不懂日历,而是「现在」在提示词、检索、工具、生成四处各自取值。把时钟收成 now() 依赖,用 Intl 读墙钟,用封闭词表把相对时间变成日期区间,再给每次外部调用打上 as-of,测试就可以冻结任意时刻、任意时区。输出侧只做核对,不改写。词表之外的相对时间直接失败,比静默猜错更便宜。这套时间处理层并不消除幻觉,但它让时间从散文变成可断言的数据,系统不再活在错误的今天。