智能体把「今天、本周、三天后」写进提示词,测试就冻不住时间,换时区就答错,检索窗口也会跟着漂。本文用 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,测试就可以冻结任意时刻、任意时区。输出侧只做核对,不改写。词表之外的相对时间直接失败,比静默猜错更便宜。这套时间处理层并不消除幻觉,但它让时间从散文变成可断言的数据,系统不再活在错误的今天。
评论