《互联网产品的 AI 架构·多能力编排与任务闭环》4.3 长任务的会话状态与记忆边界

长任务不是把每轮对话无限追加进上下文。本节定义会话状态该存事实、游标、决策与待办,而不是完整转录;用滑动 TTL、绝对 TTL、LRU 与容量上限控制生命周期,并给出跨租户隔离、单会话记忆上限与可运行的最小存储模块,最后划清它与检查点、长期记忆库的边界。

一个跨天的工单

「回声」的客服工单经常跨天:用户上午提问,坐席下午补充材料,第二天才有结论。如果每轮都把完整对话追加进上下文,第三天请求会带上前两天的全部转录,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
会话数超过 maxEntriesLRU 或低优先级capacity-entries
总 token 超过 maxTokensLRU 或低优先级capacity-tokens
全部会话都 pinned最久未访问的 pinnedcapacity-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 小时。

场景操作结果
滑动 TTL0ms 写入,900ms 访问,1,899ms 清理未过期;1,900ms 清理,原因 idle-ttl
绝对 TTL0ms 写入并设 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 与数据保留期是两套口径。
  • 记忆超限时静默截断。截断产生半句话,比明确拒绝更难排查。
  • 每次调用都自动追加整段对话。追加应是显式动作,并受单会话上限约束。
  • 用会话状态替代检查点。恢复需要执行位置与幂等键,连续性需要任务事实,两者不能混用。
  • 用会话状态替代长期记忆库。跨会话检索需要同意、删除与导出策略,生命周期完全不同。
  • 覆盖写入时延长绝对过期时刻。反复写入会让合规上限失效。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查