一个跨天的工单
「回声」的客服工单经常跨天:用户上午提问,坐席下午补充材料,第二天才有结论。如果每轮都把完整对话追加进上下文,第三天请求会带上前两天的全部转录,token 成本线性上涨,模型还会被早期无关内容干扰。更危险的是,会话状态里混进了其他租户的片段,一次错误的 sessionId 就可能把 A 公司的工单背景带进 B 公司的回复。
长任务需要状态,但状态不等于无限追加的聊天记录。会话状态应该保存「继续任务所需的最小事实」,并明确三件事:存什么、存多久、何时淘汰。本节给出一个最小实现,并讨论跨租户隔离与记忆上限。
文中的 TTL、容量与淘汰结果都来自 ai-lab 示例样本上的运行结果,不是生产实测;生产参数应以实际会话时长与合规要求为准。
存什么
会话状态只存四类东西:任务事实(槽位、订单号、已确认的约束)、执行游标(走到哪一步、下一跳是谁)、决策与授权(谁批准了什么、有效期到何时)、待办(还缺哪些材料)。完整转录只作为审计日志留在别处,不进入每一轮提示。
| 内容 | 是否进会话状态 | 理由 |
|---|---|---|
| 已确认的事实与槽位 | 是 | 下游节点需要稳定输入 |
| 执行游标与检查点引用 | 是 | 恢复与幂等依赖它 |
| 人工审批与授权 | 是 | 决定哪些动作可以继续 |
| 待补材料与待办 | 是 | 长任务的中断点 |
| 完整对话转录 | 否 | 体积大、噪声高,放审计日志 |
| 模型原始思维链 | 否 | 不可复现,也不应跨租户传播 |
一个可判定的标准是:如果一条信息删除后,下一个节点仍然能做出同样的决策,它就不该进会话状态。转录里的寒暄、重复确认与失败重试都属于这类信息。
存多久
生命周期用两个时钟共同约束。滑动 TTL 从最后一次访问起算,适合「用户还在操作」的会话;绝对 TTL 从创建时刻起算,适合「无论多活跃都必须过期」的合规要求。两者取更早者。
示例值:空闲 30 分钟过期,绝对 8 小时过期。一个会话即使每分钟都有访问,到了第 8 小时也必须失效;反过来,一个会话 30 分钟没人碰,即使创建才 1 小时也应释放。等待人工审批的会话可以 pinned,但 pinned 只影响容量淘汰,不豁免绝对 TTL。
TTL 不是数据保留期。会话状态过期只表示「不再用于继续任务」,审计日志、工单记录与合规留存有各自的期限。把两者混成一个 TTL,通常会导致要么状态留得太久,要么审计被提前删掉。
何时淘汰
容量淘汰有两种策略。lru 按最后访问时间淘汰,适合同质会话;priority 先淘汰低优先级,再按最后访问时间,适合付费用户或正在进行的任务。两种策略都把 pinned 放在最后:只要还有未固定会话,就先淘汰它们。
| 触发条件 | 淘汰对象 | 记录原因 |
|---|---|---|
| 空闲超过 idle TTL | 该会话 | idle-ttl |
| 到达绝对过期时刻 | 该会话 | absolute-ttl |
| 会话数超过 maxEntries | LRU 或低优先级 | capacity-entries |
| 总 token 超过 maxTokens | LRU 或低优先级 | capacity-tokens |
| 全部会话都 pinned | 最久未访问的 pinned | capacity-entries |
最后一行是必要的兜底:pinned 是保护,不是豁免。如果所有会话都被固定,容量上限仍然要生效,否则一次批量固定就能把内存打满。淘汰必须留下原因与 token 数,否则事后无法解释「为什么这个会话不见了」。
记忆不是无限追加
单会话也要有 token 上限。示例值 8,000 token:超过就拒绝追加,而不是悄悄截断。截断会让模型看到半句话,比明确失败更难排查;拒绝则把「该总结了」变成调用方必须处理的信号。
正确的增长方式是「摘要 + 事实」而不是「转录 + 转录」。旧轮次先被压缩成结构化事实写入状态,原始文本移到审计日志;摘要本身也要有版本与生成时间,避免把一次错误的摘要永久固化。追加动作应该是显式的 append,而不是每次调用都自动把整段对话塞进去。
这条边界也解释了为什么会话状态不能替代长期记忆库。会话状态服务「这一次任务」,长期记忆服务「这个用户下一次任务」;前者的生命周期以小时计,后者以月计,二者的隔离、同意与删除策略都不同。
跨租户隔离
唯一键必须是 tenantId + sessionId,不能只用 sessionId。用户侧传入的会话标识不可信,服务端必须从鉴权上下文取 tenantId,再用两者拼键。读取时再校验一次租户,防止键构造错误或缓存串用。
删除也要按租户执行。一个租户的会话过期、被淘汰或被用户撤回,不能影响另一个租户;批量清理必须带租户条件。日志里记录 sessionId 时,租户标识要一起记录,否则排障时无法判断串味发生在哪一层。
实现
session-state.ts 实现最小存储:滑动 TTL、绝对 TTL、条数与 token 容量、LRU/priority 淘汰、pinned 保护、单会话上限与租户隔离。它只依赖 AiError,时钟由调用方传入,因此测试完全确定。
// file: src/orch/session-state.ts
// tier: L2
// verify: node --test test/orch/context-budget.test.ts
import { AiError } from "../core/errors.ts";
/** 会话存储的一条记录;tenantId + sessionId 才是唯一键。 */
export interface SessionEntry {
tenantId: string;
sessionId: string;
tokens: number;
priority: number;
pinned: boolean;
createdAt: number;
lastAccessAt: number;
/** 绝对过期时刻;与 idleTtlMs 取更早者。 */
expiresAt: number;
}
export interface SessionInput {
tenantId: string;
sessionId: string;
tokens: number;
priority: number;
pinned?: boolean;
createdAt?: number;
expiresAt?: number;
}
export type SessionEvictionPolicy = "lru" | "priority";
export interface SessionPolicy {
idleTtlMs: number;
absoluteTtlMs: number;
maxEntries: number;
maxTokens: number;
maxTokensPerSession: number;
eviction: SessionEvictionPolicy;
}
export type SessionRemovalReason =
| "idle-ttl"
| "absolute-ttl"
| "capacity-entries"
| "capacity-tokens";
export interface SessionRemoval {
tenantId: string;
sessionId: string;
reason: SessionRemovalReason;
tokens: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `session-state: ${message}`);
}
function finite(value: number, field: string, min = 0): number {
if (!Number.isFinite(value) || value < min) fail(`${field} must be a finite number >= ${min}`);
return value;
}
function integer(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
function text(value: string, field: string): string {
if (typeof value !== "string" || value.trim() === "") fail(`${field} must be a non-empty string`);
return value;
}
function validatePolicy(policy: SessionPolicy): void {
finite(policy.idleTtlMs, "idleTtlMs", 1);
finite(policy.absoluteTtlMs, "absoluteTtlMs", 1);
integer(policy.maxEntries, "maxEntries", 1);
integer(policy.maxTokens, "maxTokens", 1);
integer(policy.maxTokensPerSession, "maxTokensPerSession", 1);
if (policy.maxTokensPerSession > policy.maxTokens) {
fail("maxTokensPerSession 不能超过 maxTokens");
}
if (policy.eviction !== "lru" && policy.eviction !== "priority") {
fail(`unknown eviction policy ${String(policy.eviction)}`);
}
}
function isBetterVictim(
candidateKey: string,
candidate: SessionEntry,
currentKey: string,
current: SessionEntry,
policy: SessionPolicy,
): boolean {
if (candidate.pinned !== current.pinned) return !candidate.pinned;
if (policy.eviction === "priority" && candidate.priority !== current.priority) {
return candidate.priority < current.priority;
}
if (candidate.lastAccessAt !== current.lastAccessAt) {
return candidate.lastAccessAt < current.lastAccessAt;
}
return candidateKey < currentKey;
}
/**
* 最小会话存储:滑动空闲 TTL、绝对 TTL、条数与 token 容量、跨租户隔离。
* 每次 get 都会刷新 lastAccessAt;put 覆盖时不会延长已有的绝对过期时刻。
*/
export class SessionStore {
private readonly policy: SessionPolicy;
private readonly sessions = new Map<string, SessionEntry>();
private readonly removalLog: SessionRemoval[] = [];
private tokenTotal = 0;
constructor(policy: SessionPolicy) {
validatePolicy(policy);
this.policy = { ...policy };
}
get size(): number {
return this.sessions.size;
}
get totalTokens(): number {
return this.tokenTotal;
}
get removals(): readonly SessionRemoval[] {
return this.removalLog.slice();
}
private key(tenantId: string, sessionId: string): string {
return `${tenantId}\u0000${sessionId}`;
}
private drop(key: string, entry: SessionEntry, reason: SessionRemovalReason, log: SessionRemoval[]): void {
this.sessions.delete(key);
this.tokenTotal -= entry.tokens;
log.push({ tenantId: entry.tenantId, sessionId: entry.sessionId, reason, tokens: entry.tokens });
}
/** 不刷新访问时间,用于观测与测试。 */
peek(tenantId: string, sessionId: string): SessionEntry | undefined {
const entry = this.sessions.get(this.key(tenantId, sessionId));
return entry === undefined ? undefined : { ...entry };
}
get(tenantId: string, sessionId: string, now: number): SessionEntry | undefined {
text(tenantId, "tenantId");
text(sessionId, "sessionId");
finite(now, "now");
this.sweep(now);
const entry = this.sessions.get(this.key(tenantId, sessionId));
if (entry === undefined || entry.tenantId !== tenantId) return undefined;
entry.lastAccessAt = now;
return { ...entry };
}
put(input: SessionInput, now: number): SessionEntry {
text(input.tenantId, "tenantId");
text(input.sessionId, "sessionId");
integer(input.tokens, "tokens");
integer(input.priority, "priority");
finite(now, "now");
if (input.tokens > this.policy.maxTokensPerSession) {
fail(`${input.sessionId}.tokens 超过单会话上限 ${this.policy.maxTokensPerSession}`);
}
this.sweep(now);
const key = this.key(input.tenantId, input.sessionId);
const existing = this.sessions.get(key);
const createdAt = existing?.createdAt ?? input.createdAt ?? now;
const requestedExpiry = input.expiresAt ?? now + this.policy.absoluteTtlMs;
const expiresAt = existing === undefined
? requestedExpiry
: Math.min(existing.expiresAt, requestedExpiry);
if (expiresAt <= now) fail(`${input.sessionId} 在写入时已过期`);
const entry: SessionEntry = {
tenantId: input.tenantId,
sessionId: input.sessionId,
tokens: input.tokens,
priority: input.priority,
pinned: input.pinned === true,
createdAt,
lastAccessAt: now,
expiresAt,
};
this.tokenTotal += input.tokens - (existing?.tokens ?? 0);
this.sessions.set(key, entry);
this.enforceCapacity();
return { ...entry };
}
/**
* 追加记忆。超过单会话 token 上限时直接拒绝,而不是悄悄截断或无限增长。
*/
append(tenantId: string, sessionId: string, deltaTokens: number, now: number): boolean {
text(tenantId, "tenantId");
text(sessionId, "sessionId");
integer(deltaTokens, "deltaTokens", 1);
finite(now, "now");
this.sweep(now);
const key = this.key(tenantId, sessionId);
const entry = this.sessions.get(key);
if (entry === undefined || entry.tenantId !== tenantId) return false;
const nextTokens = entry.tokens + deltaTokens;
if (nextTokens > this.policy.maxTokensPerSession) {
fail(`${sessionId} 追加后超过单会话上限 ${this.policy.maxTokensPerSession}`);
}
entry.tokens = nextTokens;
entry.lastAccessAt = now;
this.tokenTotal += deltaTokens;
this.enforceCapacity();
return true;
}
/** 清理空闲过期与绝对过期的记录。 */
sweep(now: number): readonly SessionRemoval[] {
finite(now, "now");
const removed: SessionRemoval[] = [];
for (const [key, entry] of this.sessions) {
const absoluteExpired = now >= entry.expiresAt;
const idleExpired = now - entry.lastAccessAt >= this.policy.idleTtlMs;
if (absoluteExpired) {
this.drop(key, entry, "absolute-ttl", removed);
} else if (idleExpired) {
this.drop(key, entry, "idle-ttl", removed);
}
}
this.removalLog.push(...removed);
return removed;
}
private selectVictim(): { key: string; entry: SessionEntry } | undefined {
let best: { key: string; entry: SessionEntry } | undefined;
for (const [key, entry] of this.sessions) {
if (best === undefined || isBetterVictim(key, entry, best.key, best.entry, this.policy)) {
best = { key, entry };
}
}
return best;
}
private enforceCapacity(): void {
while (this.sessions.size > this.policy.maxEntries || this.tokenTotal > this.policy.maxTokens) {
const victim = this.selectVictim();
if (victim === undefined) break;
const reason: SessionRemovalReason = this.sessions.size > this.policy.maxEntries
? "capacity-entries"
: "capacity-tokens";
this.drop(victim.key, victim.entry, reason, this.removalLog);
}
}
}
put 覆盖已有会话时保留原来的 createdAt,并把新的 expiresAt 与旧值取更早者。这防止调用方通过反复写入把绝对 TTL 无限延长;get 只刷新 lastAccessAt,不影响绝对过期。
示例样本上的运行结果
测试用 1,000ms 空闲 TTL、10,000ms 绝对 TTL,方便确定性复算;生产参数可以换成 30 分钟与 8 小时。
| 场景 | 操作 | 结果 |
|---|---|---|
| 滑动 TTL | 0ms 写入,900ms 访问,1,899ms 清理 | 未过期;1,900ms 清理,原因 idle-ttl |
| 绝对 TTL | 0ms 写入并设 1,500ms 到期,1,400ms 访问 | 1,499ms 检查仍未过期;1,500ms 清理,原因 absolute-ttl |
| LRU 条数 | 容量 2,写入 a、b,访问 a,再写入 c | 淘汰 b,保留 a、c |
| token 容量 | 上限 1,000,写入两个 600 token 会话 | 淘汰先写入的 a,总量回到 600 |
| 租户隔离 | 两个租户使用同一个 sessionId | 各存一条,互不可见 |
| pinned | 容量 2,一个 pinned 两个普通 | 先淘汰普通会话 |
这组结果说明三件事。第一,滑动 TTL 与绝对 TTL 必须同时存在:前者释放不活跃会话,后者防止活跃会话永久占用。第二,容量淘汰与 TTL 是两条独立路径,一个会话可能因为容量被提前淘汰,也可能因为 TTL 自然过期。第三,租户隔离必须落在键上,不能只靠调用方自觉。
与检查点、记忆库的边界
会话状态容易和 3.3 的检查点混淆。检查点保存「执行到哪、如何恢复」,属于一次运行的恢复机制;会话状态保存「任务需要知道什么」,属于多次运行之间的连续性。检查点可以比会话状态活得更久,也可以更短:一次执行结束后检查点可能被清理,但会话状态还要服务下一次追问。
会话状态也不是长期记忆库。长期记忆需要用户同意、跨会话检索、删除与导出;会话状态只需要在当前任务内一致。把两者合成一个无限增长的存储,最终会同时失去隔离性与可解释性。
小结
长任务的会话状态只存事实、游标、决策与待办,不存完整转录。生命周期由滑动 TTL 与绝对 TTL 共同约束,容量淘汰按 LRU 或优先级执行,pinned 只延迟淘汰、不豁免上限。单会话有 token 上限,超过时拒绝追加而不是静默截断;唯一键必须包含租户,删除也要按租户执行。
示例中,1,000ms 空闲 TTL 在第 1,900ms 清理,显式设置为 1,500ms 的绝对到期时刻不受访问影响;容量 2 时 LRU 淘汰最久未访问的会话,token 上限 1,000 时两个 600 token 会话只能留下一个。会话状态服务当前任务,检查点服务恢复,长期记忆服务下一次任务,三者不能互相替代。
阅读导航:上一节:4.2 节点间的上下文预算分配 · 下一节:5.1 失败分类与处理策略 。
常见错误与边界
- 把完整对话转录塞进会话状态。token 线性增长,早期噪声还会干扰当前决策。
- 只设滑动 TTL,不设绝对 TTL。持续活跃的会话可能永久占用存储与上下文。
- 把
pinned当成免死金牌。全部会话都 pinned 时仍要执行硬容量上限。 - 用
sessionId单独做键。不同租户可能碰撞,必须拼上服务端鉴权的tenantId。 - 在会话过期时删除审计记录。会话 TTL 与数据保留期是两套口径。
- 记忆超限时静默截断。截断产生半句话,比明确拒绝更难排查。
- 每次调用都自动追加整段对话。追加应是显式动作,并受单会话上限约束。
- 用会话状态替代检查点。恢复需要执行位置与幂等键,连续性需要任务事实,两者不能混用。
- 用会话状态替代长期记忆库。跨会话检索需要同意、删除与导出策略,生命周期完全不同。
- 覆盖写入时延长绝对过期时刻。反复写入会让合规上限失效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。