跳到正文
Agent 工程实践Session 管理
返回课程/Agent 工程实践
第 4 / 15 章 约 13 分钟

Session 管理

管理任务上下文、并发写入、生命周期与恢复。

这个模块在 Agent 里的位置

Session 管理不是“聊天记录容器”,而是 Agent 的任务生命周期和写入秩序。它决定:

  • 哪个请求属于哪条上下文归属。
  • 同一个 session 里,谁有资格写入。
  • 用户中途补充一句话,是打断、排队,还是被拒绝。
  • 取消时,模型、工具、子任务和外部动作要停到什么程度。
  • 服务重启后,任务是被恢复、重跑,还是被判定失败。

你可以把它理解为 Agent 的“单写者协议”。一旦没有它,多入口、多 worker、多轮工具调用会交错写入同一段上下文,导致事件顺序、工具结果和最终输出都无法可靠还原。

先记住几个概念

概念含义为什么重要
Session一条上下文归属决定历史消息、工具结果和引用归属
Run一次逻辑任务调度、恢复、计费、审计的基本单位
Active turn当前正在推进的执行保证同一 session 同一时刻只有一个写入者
Prompt开启新任务只能在 session 空闲时进入
Steer改变当前推理方向用于用户想立即纠偏
Follow-up排队补充信息不打断当前执行
Abort停止当前任务必须传播到模型、工具和动作
Leaseworker 的执行租约用于判断谁有权继续执行
Generation执行代数防止旧 worker 覆盖新 worker 的结果

从一个真实问题开始:同一个 Session 被两个入口同时写

设想一个内部资料分析 Agent:

  1. 运营在页面上点了一次“生成风险摘要”。
  2. 定时调度器几乎同时向同一个 session 注入了“补充分析近 7 天数据”。

如果没有任何互斥控制,会发生什么?

  • 两个 prompt 同时进入同一个上下文。
  • 模型一边在查资料,一边又收到新的指令。
  • 工具结果不知道应该挂在哪一次推理下面。
  • 上下文事件交错,回放时无法还原真实顺序。
  • 更糟的是,如果 session 历史是一个可编辑的 JSON 文档,两个写入者可能互相覆盖,后写的人会把前一个人写入的事件直接丢掉。

这不是理论问题。只要系统有本地输入、远程控制、定时任务、队列重试中的任意两个入口,就一定会遇到。

3.1 正确的处理方式

一个 session 在同一时刻只能有一个 active run。可以用数据库唯一约束直接表达这件事:

create table agent_runs (
  id bigint primary key,
  session_id bigint not null,
  status text not null,
  generation bigint not null,
  lease_owner text,
  lease_expires_at timestamptz,
  created_at timestamptz not null,
  updated_at timestamptz not null
);

create unique index only_one_active_run_per_session
  on agent_runs (session_id)
  where status in ('running', 'compacting', 'cancelling');

这条部分唯一索引的含义很直接:同一个 session 里,最多只能存在一个非终态 run。

当第二个请求到来时,系统有三种选择:

场景处理方式原因
用户想立即纠偏转成 steer改变当前推理方向
用户只是补充信息转成 follow-up不打断当前任务
定时任务重复触发返回 busy 或跳过不制造第二条并行上下文

为什么 Session 不能是一个随便可编辑的文档

很多初版系统会把 session 存成一个 JSON / JSONB 大字段,字段里塞满 messages。这在 demo 阶段没问题,但工程上会带来三个问题:

  1. 并发写丢失:两个入口同时读出同一份 JSON,各自 append 后写回,后写覆盖先写。
  2. 无法回放:没有事件顺序,出错后无法还原“第 3 轮为什么查了这个工具”。
  3. 锁粒度过大:为了安全,不得不给整份 JSON 加锁,模型执行 10 分钟,锁就挂 10 分钟。

更稳的做法是:

  • session 历史用 append-only event log。
  • 每个 event 有 (session_id, run_id, seq)。
  • 写入用唯一键或乐观锁保证顺序。
  • 模型执行时只允许一个 writer。
  • reader 通过 cursor 或 snapshot 读取,不直接改历史。

示意表结构:

create table agent_run_events (
  id bigint primary key,
  session_id bigint not null,
  run_id bigint not null,
  seq int not null,
  event_type text not null,
  payload jsonb not null,
  created_at timestamptz not null,
  unique (session_id, run_id, seq)
);

有了 unique(session_id, run_id, seq),并发写入时不会出现两条相同 seq 的事件;回放时也能严格按顺序还原。

多入口输入应该走哪条路

流程图加载中…

这三种语义不能混:

  • prompt 是新任务,不应该和当前 run 并发。
  • steer 会影响当前推理,适合用户明确说“不要这么查,换个方向”。
  • follow-up 只是补充信息,适合“再顺带看一下近 7 天”。

如果产品层只有一个“发送”按钮,也要在 runtime 层根据 session 状态自动选择 prompt / steer / follow-up,而不是把并发问题交给模型自己理解。

状态机:什么时候算结束

流程图加载中…

几个容易忽略的点:

  1. Completed 必须是持久化终态,不只是 UI 上的“已完成”。
  2. Cancelled 必须在取消传播完成后才写入。
  3. Compacting 期间通常不应再开启新的 prompt。
  4. TimedOut 和 Failed 要区分,因为 timeout 往往可重试。
  5. 进入终态后,active run 唯一索引会自动释放,session 才能接受新 prompt。

取消不是把按钮变灰

用户点取消后,系统至少要做四件事:

  1. runtime 把 run 标记为 cancelling。
  2. abort 当前模型请求。
  3. 把 AbortSignal 传给正在执行的工具。
  4. 阻止还没执行的外部动作。

流程图加载中…

如果只改 UI 状态,后台可能还在继续:

  • 模型继续生成。
  • 工具继续查库。
  • 子任务继续跑。
  • 更严重的是,pending action 可能继续发邮件、写数据或调用外部系统。

这类问题的典型表现是:页面显示“已取消”,几分钟后用户又收到一封“任务完成”的邮件。

服务重启后的恢复

假设 worker A 正在执行一个 run,然后进程挂了。几分钟后 worker B 接管。此时不能简单地让 A 恢复后继续写结果,因为 B 可能已经推进到新的状态。

工程上用两个字段解决:

  • lease_expires_at:租约过期后,允许新 worker 接管。
  • generation:结果只能由当前代数的 worker 写入。

流程如下:

流程图加载中…

这意味着:

  • 旧 worker 可以留下现场,方便排查。
  • 新 worker 才能写“当前结果”。
  • 外部动作必须再配合幂等 key,避免重复执行。

和传统后端的关系

如果你做过传统后端,可以把这里的很多问题直接迁移过来:

Agent 里的概念传统后端里的对应物
Session分布式 session / 用户态
Run一次请求或一次异步 job
Active run 唯一约束分布式锁 / 单写者
Event logappend-only 审计日志
Generationoptimistic lock 版本号
Lease租约 / 心跳续期

区别在于,Agent 的“一次请求”中间会夹着模型推理、工具调用和上下文增长,所以不能简单套 HTTP request/response 的生命周期。

一个足够工程化的表结构

create table agent_sessions (
  id bigint primary key,
  tenant_id bigint not null,
  user_id bigint not null,
  title text,
  created_at timestamptz not null,
  updated_at timestamptz not null
);

create table agent_runs (
  id bigint primary key,
  session_id bigint not null references agent_sessions(id),
  status text not null,
  generation bigint not null,
  lease_owner text,
  lease_expires_at timestamptz,
  timeout_at timestamptz,
  cancel_requested_at timestamptz,
  terminal_status text,
  terminal_at timestamptz,
  created_at timestamptz not null,
  updated_at timestamptz not null
);

create unique index only_one_active_run_per_session
  on agent_runs (session_id)
  where status in ('running', 'compacting', 'cancelling');

create index active_run_timeout_idx
  on agent_runs (status, timeout_at)
  where status in ('running', 'compacting', 'cancelling');

create index session_run_history_idx
  on agent_runs (session_id, created_at desc);

几个设计点:

  1. session 和 run 分表,不把执行状态塞进 session 标题字段。
  2. active run 用部分唯一索引兜底,防止并发插入。
  3. timeout 扫描只查非终态 run,避免全表扫。
  4. terminal_status 和 terminal_at 必须落库。
  5. 上下文 checkpoint 不放这张表,通常放对象存储,数据库只存指针。

事件流示例

[
    {
        "sessionId": "session-42",
        "runId": "run-9f27",
        "seq": 1,
        "type": "run.started",
        "payload": {
            "inputMode": "prompt",
            "tenantId": 7
        }
    },
    {
        "sessionId": "session-42",
        "runId": "run-9f27",
        "seq": 2,
        "type": "model.request.started",
        "payload": {
            "turnId": "turn-103"
        }
    },
    {
        "sessionId": "session-42",
        "runId": "run-9f27",
        "seq": 3,
        "type": "tool.started",
        "payload": {
            "toolCallId": "call-551",
            "toolName": "query_events"
        }
    },
    {
        "sessionId": "session-42",
        "runId": "run-9f27",
        "seq": 4,
        "type": "run.cancel_requested",
        "payload": {
            "reason": "user_click"
        }
    },
    {
        "sessionId": "session-42",
        "runId": "run-9f27",
        "seq": 5,
        "type": "run.finished",
        "payload": {
            "terminalStatus": "cancelled"
        }
    }
]

这份数据能回答几个很实际的问题:

  • 哪个 run 被取消。
  • 是谁发起的。
  • 当时正在调哪个工具。
  • 事件顺序是什么。
  • 是否已经写入终态。

常见工程错误

错误实际后果正确做法
session 存成一个可编辑 JSON 大字段并发写互相覆盖append-only event log + seq
没有唯一 active run两个入口同时写入同一上下文部分唯一索引
只用内存状态控制 busy服务重启后状态丢失数据库持久化 run
UI 取消后不 abort 工具后台继续执行并发送结果AbortSignal 传播
旧 worker 恢复后继续写结果覆盖新 worker 的状态generation fencing
没有 terminal statusrun 永远 running显式终态字段
没有事件 seq重放时重复或乱序(session_id, run_id, seq) 唯一
信任客户端传来的 sessionId跨租户读写服务端校验 tenant ownership

上线前检查

  1. 同一 session 是否最多只有一个 active run。
  2. session 历史是否 append-only 且有顺序。
  3. prompt / steer / follow-up / abort 是否有明确入口语义。
  4. 取消是否传播到模型、工具、子任务和动作。
  5. run 是否有 timeout 和 terminal status。
  6. worker 是否用 lease + generation。
  7. 外部动作是否有幂等 key。
  8. 事件流是否能按 runId 完整回放。