Session 管理
管理任务上下文、并发写入、生命周期与恢复。
内容同步:2026年10月25日 · 文档版本 31
这个模块在 Agent 里的位置
Session 管理不是“聊天记录容器”,而是 Agent 的任务生命周期和写入秩序。它决定:
- 哪个请求属于哪条上下文归属。
- 同一个 session 里,谁有资格写入。
- 用户中途补充一句话,是打断、排队,还是被拒绝。
- 取消时,模型、工具、子任务和外部动作要停到什么程度。
- 服务重启后,任务是被恢复、重跑,还是被判定失败。
你可以把它理解为 Agent 的“单写者协议”。一旦没有它,多入口、多 worker、多轮工具调用会交错写入同一段上下文,导致事件顺序、工具结果和最终输出都无法可靠还原。
先记住几个概念
| 概念 | 含义 | 为什么重要 |
|---|---|---|
| Session | 一条上下文归属 | 决定历史消息、工具结果和引用归属 |
| Run | 一次逻辑任务 | 调度、恢复、计费、审计的基本单位 |
| Active turn | 当前正在推进的执行 | 保证同一 session 同一时刻只有一个写入者 |
| Prompt | 开启新任务 | 只能在 session 空闲时进入 |
| Steer | 改变当前推理方向 | 用于用户想立即纠偏 |
| Follow-up | 排队补充信息 | 不打断当前执行 |
| Abort | 停止当前任务 | 必须传播到模型、工具和动作 |
| Lease | worker 的执行租约 | 用于判断谁有权继续执行 |
| Generation | 执行代数 | 防止旧 worker 覆盖新 worker 的结果 |
从一个真实问题开始:同一个 Session 被两个入口同时写
设想一个内部资料分析 Agent:
- 运营在页面上点了一次“生成风险摘要”。
- 定时调度器几乎同时向同一个 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 阶段没问题,但工程上会带来三个问题:
- 并发写丢失:两个入口同时读出同一份 JSON,各自 append 后写回,后写覆盖先写。
- 无法回放:没有事件顺序,出错后无法还原“第 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,而不是把并发问题交给模型自己理解。
状态机:什么时候算结束
流程图加载中…
几个容易忽略的点:
- Completed 必须是持久化终态,不只是 UI 上的“已完成”。
- Cancelled 必须在取消传播完成后才写入。
- Compacting 期间通常不应再开启新的 prompt。
- TimedOut 和 Failed 要区分,因为 timeout 往往可重试。
- 进入终态后,active run 唯一索引会自动释放,session 才能接受新 prompt。
取消不是把按钮变灰
用户点取消后,系统至少要做四件事:
- runtime 把 run 标记为 cancelling。
- abort 当前模型请求。
- 把 AbortSignal 传给正在执行的工具。
- 阻止还没执行的外部动作。
流程图加载中…
如果只改 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 log | append-only 审计日志 |
| Generation | optimistic 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);
几个设计点:
- session 和 run 分表,不把执行状态塞进 session 标题字段。
- active run 用部分唯一索引兜底,防止并发插入。
- timeout 扫描只查非终态 run,避免全表扫。
- terminal_status 和 terminal_at 必须落库。
- 上下文 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 status | run 永远 running | 显式终态字段 |
| 没有事件 seq | 重放时重复或乱序 | (session_id, run_id, seq) 唯一 |
| 信任客户端传来的 sessionId | 跨租户读写 | 服务端校验 tenant ownership |
上线前检查
- 同一 session 是否最多只有一个 active run。
- session 历史是否 append-only 且有顺序。
- prompt / steer / follow-up / abort 是否有明确入口语义。
- 取消是否传播到模型、工具、子任务和动作。
- run 是否有 timeout 和 terminal status。
- worker 是否用 lease + generation。
- 外部动作是否有幂等 key。
- 事件流是否能按 runId 完整回放。