会话生命周期
读者: Gateway 开发者与维护者 源文件:
gateway/session.py(约 1200 行 +session_*.py兄弟模块)、gateway/run.py(约 5500 行门面 +run_*.py各阶段)、gateway/config.py最后更新: 2026-06-16
概览
会话代表 agent 与一个或多个用户在某消息平台上的一段连续对话。会话生命周期管什么对话何时持久化、何时重置、如何在 gateway 重启后存活,以及并发操作期间消息如何排队。
会话系统主要在两个模块:
gateway/session.py——数据模型(SessionSource、SessionEntry、SessionContext)、键生成(build_session_key)与主存储(SessionStore)。gateway/run.py——Gateway runner(GatewayRunner)门面,把会话接入消息处理管线;各阶段在run_*.py兄弟模块:会话整理(run_watchers.py)、agent 缓存(run_agent_cache.py)、重启恢复(session_recovery.py)、消息排队(run_busy.py)。
1. SessionSource——消息来源描述符
SessionSource 是一条关于消息从哪来的冻结记录。它附在每条入站 MessageEvent 上,用于路由、隔离与上下文注入。
字段
| 字段 | 类型 | 默认 | 描述 |
|---|---|---|---|
platform | Platform | (必填) | 标识消息平台的枚举(telegram、discord、slack、signal、whatsapp、matrix、local 等)。 |
chat_id | str | (必填) | 平台级聊天/群组/频道标识符。经适配器的 chat_id_key 变换路由。 |
chat_name | Optional[str] | None | 聊天或群组的可读名。 |
chat_type | str | "dm" | "dm"、"group"、"channel"、"thread" 之一。控制会话键生成与隔离。 |
user_id | Optional[str] | None | 平台相关用户标识符。用于授权与按用户会话隔离。 |
user_name | Optional[str] | None | 消息作者显示名。注入系统提示词。 |
thread_id | Optional[str] | None | 论坛话题 / Discord 线程 / Slack 线程标识符。区分线程化对话。 |
chat_topic | Optional[str] | None | 频道话题或描述(Discord 频道话题、Slack 频道用途)。 |
user_id_alt | Optional[str] | None | 平台相关的稳定备选 ID(Signal UUID、飞书 union_id)。当 user_id 是临时的时使用。 |
chat_id_alt | Optional[str] | None | Signal 群组内部 ID——把 Signal 群组 V2 标识符映射到其规范形式。 |
is_bot | bool | False | 消息作者是 bot 或 webhook 时为真(Discord bots)。 |
guild_id | Optional[str] | None | Discord guild / Slack workspace / Matrix 服务器范围标识符。 |
parent_chat_id | Optional[str] | None | 当 chat_id 指向线程时的父频道。 |
message_id | Optional[str] | None | 触发消息的 ID。用于置顶/回复/表情操作与 Discord ID 注入(注入的 [Triggering message id: …] 注记只搭在 API 绑定的消息上;持久化用户行保留所写文本)。 |
role_authorized | bool | False | 当适配器经平台角色(而非单个用户 ID)授予访问时为真。 |
关键方法
description(property:str)——人类可读摘要,如"DM with Alice"、"group: My Group, thread: 12345"。to_dict()/from_dict()——为持久化到sessions.json的序列化往返。
2. SessionEntry——活跃会话记录
SessionEntry 是按会话的元数据记录,存于内存并持久化到 {sessions_dir}/sessions.json。每条记录把一个 session_key 映射到它当前的 session_id。
字段
| 字段 | 类型 | 默认 | 描述 |
|---|---|---|---|
session_key | str | (必填) | 标识对话车道的确定性键(见 §4)。 |
session_id | str | (必填) | 这一次具体对话化身的唯一标识。格式:YYYYMMDD_HHMMSS_<8hex>。 |
created_at | datetime | (必填) | 本次会话化身创建时间。 |
updated_at | datetime | (必填) | 上次活动时间戳,用于资源整理。 |
origin | Optional[SessionSource] | None | 创建本会话的来源,用于投递路由。 |
display_name | Optional[str] | None | 聊天显示名(来自 SessionSource.chat_name)。 |
platform | Optional[Platform] | None | 持久化的平台枚举,用于跨重启路由。 |
chat_type | str | "dm" | 聊天类型,也持久化用于策略查找。 |
input_tokens | int | 0 | 累计消耗的 LLM 输入(提示词)token。 |
output_tokens | int | 0 | 累计消耗的 LLM 输出(补全)token。 |
cache_read_tokens | int | 0 | 累计提示词缓存读取 token。 |
cache_write_tokens | int | 0 | 累计提示词缓存写入 token。 |
total_tokens | int | 0 | 跨所有轮次的 token 总数。 |
estimated_cost_usd | float | 0.0 | 估计的累计美元成本。 |
cost_status | str | "unknown" | 成本跟踪状态标签。 |
last_prompt_tokens | int | 0 | 上次 API 上报的提示词 token 数。用于准确的压缩预检。 |
布尔标志(状态机)
SessionEntry 有若干布尔标志,构成一个简单状态机,控制下次访问时的会话行为。
| 标志 | 类型 | 默认 | 描述 |
|---|---|---|---|
was_auto_reset | bool | False | 当显式挂起导致替换会话时设置。也为历史记录保留。 |
auto_reset_reason | Optional[str] | None | 显式挂起为 "suspended";旧行可能保留历史重置原因。 |
reset_had_activity | bool | False | 被替换的会话此前是否有活动。 |
is_fresh_reset | bool | False | 由显式 /new 或 /reset 设置。首条消息时触发话题/频道 skill 重新注入。与 was_auto_reset 区分,避免误导性的"session expired"提示。 |
expiry_finalized | bool | False | 为恢复保留的历史 finalization 围栏;无计时器写它。 |
suspended | bool | False | 硬强制擦除信号。由 /stop 或卡死循环升级(连续 3+ 次重启失败)设置。下次 get_or_create_session() 时,无视 resume_pending 强制新 session_id。 |
resume_pending | bool | False | 软恢复标记。由 recover_interrupted_turns()(崩溃恢复一个被标记、未回复的轮次)或 drain 超时设置。下次访问时保留既有 session_id——用户在同一转录上继续。下一次成功轮次完成后清除。 |
resume_reason | Optional[str] | None | 为何标记 resume:"restart_timeout"、"shutdown_timeout"、"restart_interrupted"。 |
last_resume_marked_at | Optional[datetime] | None | 上次 resume-pending 标记的时间戳。 |
状态迁移逻辑(get_or_create_session)
┌──────────┐
│ Incoming │
│ Message │
└────┬─────┘
│
▼
┌──────────────────────┐
│ session_key exists │──── No ──► Create fresh SessionEntry
│ AND !force_new │
└──────────┬───────────┘
│ Yes
▼
┌──────────────────────┐
│ entry.suspended? │──── Yes ──► Auto-reset: new session_id
└──────────┬───────────┘ (reason="suspended")
│ No
▼
┌──────────────────────┐
│ entry.resume_pending?│──── Yes ──► Return existing entry
└──────────┬───────────┘ (preserve session_id)
│ No Clear flag on next successful turn
▼
┌──────────────────────┐
│ Policy says reset? │──── Yes ──► Auto-reset: new session_id
└──────────┬───────────┘ (reason="idle"/"daily")
│ No
▼
┌──────────────────────┐
│ Return existing │
│ entry, bump │
│ updated_at │
└──────────────────────┘
get_or_create_session() 中的优先级:
suspended=True→ 总是强制重置(硬擦除)resume_pending=True→ 保留 session_id(软恢复)- 无触发 → 返回既有条目(bump
updated_at)
3. SessionStore——存储与操作
SessionStore 是主存储层。它维护一个内存 dict(_entries)并持久化到 sessions.json,以 SQLite(SessionDB)作为会话元数据与消息转录的规范存储。
构造器
SessionStore(sessions_dir: Path, config: GatewayConfig, has_active_processes_fn=None)
sessions_dir——sessions.json所在目录。config——用于路由与整理设置的GatewayConfig实例。has_active_processes_fn——可选回调,按session_key查运行中的后台进程。有活跃进程的会话受保护,不被路由条目清理。
操作(方法)
| 方法 | 描述 |
|---|---|
get_or_create_session(source, force_new=False) | 核心入口。返回既有或创建新 SessionEntry。评估显式挂起与重启恢复状态。创建/结束 SQLite 记录。 |
update_session(session_key, last_prompt_tokens=None) | 一次交互后的轻量元数据更新。bump updated_at,可选记录 last_prompt_tokens。 |
reset_session(session_key, display_name=None) | 显式重置(来自 /new 或 /reset)。创建新 session_id,设 is_fresh_reset=True。结束旧 SQLite 会话,创建新的。 |
switch_session(session_key, target_session_id, *, expected_session_id=None) | 切到另一个既有会话 ID(来自 /resume)。结束当前 SQLite 会话,重开目标。带 expected_session_id= 时重定位是 compare-and-swap:当键已不再指向该会话时返回 None 而不切换,因此一个在 await 期间对照快照解析的调用方(异步委派重钉、Telegram 话题绑定自愈)无法覆盖一次并发的 /new 或 /resume。 |
suspend_session(session_key) | 把会话标记为 suspended=True(来自 /stop)。下次访问时强制自动重置。 |
mark_resume_pending(session_key, reason) | 把会话标记为 resume_pending=True(来自 drain 超时)。下次访问时保留 session_id。不会覆盖 suspended=True。 |
clear_resume_pending(session_key) | 在成功恢复轮次后清除 resume_pending。gateway 在 run_conversation() 返回后调用。 |
recover_interrupted_turns(max_age_seconds) | 崩溃恢复:把死掉进程留下的持久活动轮次标记提升为 resume_pending=True(restart_interrupted)。无标记的会话已完成其轮次,不动它。在不干净关闭后启动时调用。 |
prune_old_entries(max_age_days) | 丢弃早于 max_age_days(按 updated_at)的条目。跳过 suspended 条目与有活跃进程的会话。 |
list_sessions(active_minutes=None) | 返回所有会话,可按近期活动过滤。按 updated_at 降序。 |
lookup_by_session_id(session_id) | 找某持久化会话 ID 的活跃 SessionEntry。 |
has_any_sessions() | 检查是否曾创建过会话(用 SQLite 查历史,而不只是内存 dict)。 |
append_to_transcript(session_id, message, skip_db=False) | 向 SQLite 转录追加一条消息。skip_db=True 在 agent 已持久化时防止重复写。 |
rewrite_transcript(session_id, messages) | 全量替换会话转录(供 /retry、/undo、/compress 使用)。 |
load_transcript(session_id) | 加载某会话 SQLite 转录的所有消息。 |
rewind_session(session_id, n=1) | 经软删回退 n 个用户轮次(保留审计轨迹);是 SessionDB.rewind_user_turn(hermes_state_rewind.py)的薄封装,与 CLI /undo//retry 及 TUI 共享这一回退。返回 {rewound_count, turns_undone, target_text}。 |
内部辅助
_ensure_loaded()/_ensure_loaded_locked()——把sessions.json载入_entriesdict。_save()——经临时文件 +atomic_replace原子写sessions.json。_generate_session_key(source)——带配置参数委托给build_session_key()。
存储布局
{sessions_dir}/
sessions.json # 内存 _entries dict,持久化为 JSON
映射 session_key → SessionEntry(仅元数据)
{session_id}.jsonl #(遗留,在 spec 002 中移除)
规范转录存储是经 SessionDB(来自 hermes_state)的 SQLite。sessions.json 文件持久化 session_key → session_id 映射与条目元数据(标志、时间戳、token 计数)。若 SQLite 不可用,存储回退到 JSONL,但这是降级路径。
4. 会话键生成规则
会话键是标识对话车道的确定性字符串。由 build_session_key(source, group_sessions_per_user, thread_sessions_per_user) 生成。
键格式
agent:main:{platform}:{chat_type}[:{chat_id}][:{thread_id}][:{participant_id}]
DM 规则
| 场景 | 键 |
|---|---|
| 带 chat_id 的 DM | agent:main:telegram:dm:12345 |
| 带 chat_id + 线程的 DM | agent:main:telegram:dm:12345:thread_678 |
| 无 chat_id、带 participant_id 的 DM | agent:main:signal:dm:user_abc |
| 无 chat_id 也无 participant_id 的 DM | agent:main:telegram:dm |
| WhatsApp DM(规范化后) | agent:main:whatsapp:dm:{canonical_number} |
- DM 在有
chat_id时总是带上它,隔离每条私密对话。 thread_id进一步区分同一 DM 聊天内的线程化 DM。- 无
chat_id时,回退到user_id_alt或user_id作为 participant_id。 - 无任何标识符时,该平台上所有 DM 折叠为一个共享会话。
群组/频道规则
| 场景 | 键 |
|---|---|
| 群组聊天 | agent:main:telegram:group:-10012345 |
| 群组聊天,按用户隔离 | agent:main:telegram:group:-10012345:user_abc |
| 群组内线程,共享 | agent:main:discord:group:12345:thread_678 |
| 群组内线程,按用户 | agent:main:discord:group:12345:thread_678:user_abc |
| 频道 | agent:main:slack:channel:C12345 |
| WhatsApp 群组(规范化后) | agent:main:whatsapp:group:{canonical_id}:{participant} |
chat_id标识父群组/频道。thread_id区分该父级内的线程。- 按用户隔离(追加
participant_id)由以下控制:group_sessions_per_user(默认True)——群组/频道会话按用户隔离。thread_sessions_per_user(默认False)——线程默认共享(Telegram 论坛话题、Discord 线程、Slack 线程每个线程共享一个会话)。
participant_id=user_id_alt或user_id(按此优先级)。- WhatsApp 标识符经规范化处理 JID/LID 别名翻转。
特殊情形:WhatsApp
WhatsApp 电话号码经 canonical_whatsapp_identifier() 处理,它剥去 @s.whatsapp.net 后缀并规范化为 E.164 格式。这在桥对同一号码返回不同别名形式时防止会话碎片化。
5. 多用户隔离策略
多用户隔离决定同一聊天中的多个用户共享一段对话,还是各得自己的私密会话。
决策逻辑(is_shared_multi_user_session)
def is_shared_multi_user_session(source, *, group_sessions_per_user, thread_sessions_per_user):
if source.chat_type == "dm":
return False # DM 总是私密
if source.thread_id:
return not thread_sessions_per_user # 线程:除非按用户,否则共享
return not group_sessions_per_user # 群组:除非共享,否则隔离
小结
| 聊天类型 | 默认 | 配置控制 |
|---|---|---|
| DM | 私密(绝不共享) | N/A |
| 群组/频道 | 按用户隔离 | group_sessions_per_user(默认 True) |
| 线程(论坛、discord) | 共享(所有参与者看到同一上下文) | thread_sessions_per_user(默认 False) |
对系统提示词的影响
当 shared_multi_user_session=True 时,系统提示词省略固定用户名,改为陈述:"Multi-user {thread|session} — messages are prefixed with [sender name]. Multiple users may participate."。各发送者名由 gateway 在运行时加在每条用户消息前,从而保留提示词缓存(系统提示词不按轮次变化)。
6. 显式对话边界
空闲与挂钟时间绝不轮转对话。/new 与 /reset 创建显式边界;上下文压缩继续管理长历史。遗留计时器配置被忽略。
显式挂起仍会在下一条入站轮次创建边界。恢复尊重显式与历史 finalization 边界,而不是重开它们。仅资源驱逐与 WebSocket 孤儿回收让对话保持可恢复。
7. 重启恢复流程
重启恢复系统确保在途会话跨 gateway 重启、崩溃与 drain 超时存活。它是 issue #7536 的解决方案。
启动恢复序列
Gateway starts
│
▼
┌───────────────────────────────┐
│ Check for .clean_shutdown │── Exists? ──► Skip suspension (clean exit)
│ marker │
└───────────────────────────────┘
│ Missing
▼
┌───────────────────────────────┐
│ _recover_unclean_sessions() │── Marked turn with a persisted reply
│ │ → delivery ledger (sent, marked);
│ │ marked turn without one →
│ │ resume_pending (once)
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ _suspend_stuck_loop_sessions()│── Suspends sessions that have been
│ │ active across 3+ restarts
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ Queue inbound messages while │
│ startup restore runs │
│ (_startup_restore_in_progress)│
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ For each adapter, find │
│ resume_pending sessions → │
│ synthesize MessageEvent and │
│ run _handle_message to let │
│ the agent auto-continue │
└───────────────────────────────┘
崩溃恢复(_recover_unclean_sessions)
在 gateway 启动且无 .clean_shutdown 标记(崩溃或意外退出)时调用。它只对持久的活动轮次标记起作用,绝不基于新近度:崩溃前刚活跃过的聊天已完成其轮次,不会被再次应答。
标记在轮次开始时设置,一直保留到最终回复进入投递账本(适配器在 record_delivery_obligation 后立即释放它),或直到不再欠任何东西(流式回复、被抑制或空响应)。因此启动时遗留标记意味着两种情况之一:
- 回复已持久化但从未记账。 存储的转录回复被记为一条无主账本行,标记清除;启动扫尾把它带"Recovered reply"通知投递一次。轮次不重新生成。回复按实时投递本会采用的方式判定:内部轮次上的裸静默标记(
[SILENT]、NO_REPLY……),或聊天策略对诊断唤醒的回复被静音的,不欠任何东西(标记清除,不发送也不恢复)。人类轮次的裸静默标记变成实时路径发出的同一条"returned only a silence marker"通知。 - 无回复被持久化。
recover_interrupted_turns()设resume_pending=True、resume_reason="restart_interrupted",轮次自动恢复一次。
标记的开始时间以 aware UTC 存储并按 epoch 秒比较,因此在不同本地时区重启(DST 变化、容器与 unit TZ 差异)既不会把新鲜标记当陈旧丢弃,也不会把上一轮的回复当作本轮的。旧构建写入的标记(naive 本地时间)按宿主机本地时间读取。
已在账本中的轮次由账本扫尾重新投递,它也清除该会话的任何 resume_pending,因此绝不会既投递又重新应答。
卡死循环检测(_suspend_stuck_loop_sessions)
经一个 JSON 文件({HERMES_HOME}/restart_counts.json)统计连续重启。若某会话跨 3+ 次连续重启一直活跃,则自动挂起它,让用户得到干净起点。
Drain 超时标记
优雅关闭/重启时,drain 系统对任何在 drain 超时触发时正处轮次中的会话调用 mark_resume_pending()。原因:
"restart_timeout"——重启 drain 期间被杀"shutdown_timeout"——关闭 drain 期间被杀"restart_interrupted"——一个被标记、未回复轮次的崩溃恢复(来自recover_interrupted_turns)
三个原因都在 _AUTO_RESUME_REASONS 中,符合启动自动恢复条件。
下次访问时自动恢复
当 get_or_create_session() 遇到 resume_pending=True:
- 它返回既有条目,不创建新
session_id。 - 既有转录完整加载。
- 标记不在此处清除——它保留到下一次成功轮次完成(
run_conversation()返回真实响应后由 gateway 调clear_resume_pending())。 - 若恢复的轮次再次被中断,
resume_pending标志保持置位,下次重启会重试。卡死循环计数器处理终态升级(3 次重试 → 挂起)。
干净关闭标记(.clean_shutdown)
在优雅关闭末尾写入。下次启动时:
- 若存在:完全跳过崩溃恢复并丢弃孤儿轮次标记。活跃 agent 已被排空,因此无会话卡死。
- 然后删除该标记。
这防止 hermes update、hermes gateway restart 或 /restart 之后出现不想要的自动重置。
8. 消息排队流程
消息排队系统处理两种场景:
- 中断后续消息——当用户在 agent 处理期间发多条消息时,后续消息作为单槽待处理消息排队。
/queueFIFO——显式/queue命令,每条都必须按顺序各自产生完整 agent 轮次,不合并。
数据结构
adapter._pending_messages: Dict[session_key, MessageEvent]
└── 每会话单个"下一个"槽。重复发送时覆盖
(突发合并)。与照片突发后续消息共享。
self._queued_events: Dict[session_key, List[MessageEvent]]
└── 溢出缓冲。每次 /queue 调用在槽被占时追加到此。
每次排空后逐个提升。
入队(_enqueue_fifo)
_enqueue_fifo(session_key, event, adapter)
│
▼
┌───────────────────────────────────────┐
│ Is slot free? │
│ (session_key NOT in _pending_messages)│── Yes ──► Place event in slot
└───────────────────────────────────────┘
│ No
▼
Append to _queued_events[session_key] (overflow tail)
出队 / 提升(_promote_queued_event)
在槽被消费后于排空点调用。若有溢出项:
- 当
pending_event is None(槽空)时,返回溢出头作为新事件。 - 当
pending_event存在时,把溢出头暂存到槽,供下次递归。 - 若无适配器可用,推回
_queued_events(不静默丢弃)。
队列深度
_queue_depth(session_key, adapter) 返回 len(overflow) + (1 if slot occupied else 0)。
清除
某会话的排队事件在 /new 与 /reset(经 _handle_reset_command)时清除。/stop 丢弃用户在被中断轮次中发送的单槽后续消息。停在任一存储中的内部唤醒(一次异步委派完成通知、一次看板/cron notify+wake)在三个命令下都存活:_interrupt_and_clear_session 把它留在槽中(当一条被丢弃的人类后续占着槽时把它从溢出提升出来),让命令后排空立即启动它,而不是让会话空等到下一条用户消息。一个钉在刚被 /new 关闭的会话上的唤醒是否仍可运行,在处理时决定(_resolve_async_delegation_session,fail-closed)。
两个命令还结束该会话的后台委派(tools.async_delegation.interrupt_for_session,按路由键与派发者的持久会话 id 选择):_interrupt_and_clear_session 为繁忙路径把停止扇出,_handle_stop_command 对一个派发轮次已结束的空闲会话做同样的事(回复"Stopped"而非"No active task to stop")。该轮次自身的硬中断到不了那些单元——它们在派发时已从 _active_children 分离——因此若不扇出,它们会运行到完成并在数分钟后唤醒聊天。每个被停单元仍正常收尾,并以 status="interrupted" 加子进程部分输出作为其完成通知重新进入。/new 与 /reset 在 _handle_reset_command 中已做过此事;共享辅助器更早的调用在那里幂等(请求两次硬中断就是一次停止)。
FIFO 不变量
每次 /queue 调用恰好产生一个完整 agent 轮次,按 FIFO 顺序,不合并。单槽 _pending_messages + 溢出 _queued_events 的设计确保活动轮次期间的重复发送不会导致乱序处理。
9. 会话上下文注入
SessionContext 由 SessionSource 与 GatewayConfig 构建,注入 agent 的系统提示词。它告诉 agent:
- 当前消息从哪来
- 连接了哪些平台
- 它能把定时任务输出投递到哪
- 这是否是共享多用户会话
构建(build_session_context)
def build_session_context(source, config, session_entry=None) -> SessionContext
- 从 config 收集已连接平台。
- 收集每个平台的主频道。
- 经
is_shared_multi_user_session()判定shared_multi_user_session。 - 若提供
session_entry,附加上会话元数据(键、id、时间戳)。
PII 脱敏(build_session_context_prompt)
动态系统提示词段(## Current Session Context)可在发给 LLM 前可选地脱敏个人可识别信息:
- 用户 ID →
user_<12hex>(SHA-256 前缀) - 聊天 ID →
<platform>:<12hex>或仅<12hex> - 免于脱敏的平台:Discord(
@mentions需要原始 ID),以及任何未标pii_safe的插件注册平台。
脱敏只作用于系统提示词文本。路由、会话键与适配器操作总是用原始值。
10. 后台整理
_session_housekeeping_watcher 周期性扫过空闲缓存的 agent,在内存压力下丢弃缓存条目,并每小时清理旧路由条目。它绝不因空闲或一天中的时刻而结束转录。
TTL、LRU 与压力驱逐在软释放客户端之前把活跃转录提交给记忆 provider。活动轮次保持受保护;终端、浏览器与后台进程资源在软释放后存活。路由条目清理保留规范 SQLite 转录,活跃进程保护其路由条目不被清理。历史 expiry_finalized 标志仍是恢复围栏,但不再由计时器 watcher 写入。
11. Agent 缓存
gateway 维护一个以 session_key 为键的 AIAgent 实例 LRU 缓存,以跨轮次保留提示词缓存。
缓存属性
- 最大容量: 128 条(
agent.agent_cache.max_size,默认_AGENT_CACHE_MAX_SIZE)。 - 驱逐策略: 最近最少使用(经
OrderedDict的 LRU)。 - 空闲 TTL: 3600s(1h)——
agent.agent_cache.idle_ttl_secs,由_session_housekeeping_watcher强制执行。 - 内存预算:
agent.agent_cache.memory_high_mb(默认auto)——见下。 - 锁:
_agent_cache_lock(threading)保证线程安全。
内存压力驱逐
一个缓存的 agent 钉住 _session_messages,即包含工具输出的完整活跃转录——在一个有 100+ 次工具调用的会话上可达数十 MB。条目上限与空闲 TTL 都对此视而不见:一个服务大量聊天的 gateway 把每个温热转录都常驻内存(TTL 内跑过一轮的 agent 永不会被空闲扫掉),因此 RSS 一路攀升,直到 cgroup 限流且 SIGTERM 在 systemd 停止超时内无法刷完(#80764)。
_sweep_agent_cache_under_pressure() 是阀门。每个 watcher tick 它把匿名内存与 memory_high_mb 比较——当 gateway 跑在 cgroup 限制下时用 cgroup 自己的 memory.stat anon(预算计在那个 scope 上,因此同单元子进程如 execute_code 内核也计入;#110549),否则用进程自己的匿名 RSS;超预算时,它经上限执行器使用的同一条软路径(_commit_then_release_soft)驱逐 LRU agent,然后跑 malloc_trim,让释放的 arena 真正还给 OS。被驱逐的会话在下一轮从持久化会话重建转录。
三类会话绝不被丢弃:
- 当前正处轮次中的 agent(它们的客户端与沙箱在用);
protect_recent个最近最常使用的会话(它们的提示词缓存最值钱);- 任何活跃转录尚未写完盘的会话——
transcript_persistence_caught_up()把_last_flushed_db_idx与len(_session_messages)比较,即 FTS 写损坏守护在保住活跃历史而非滞后转录时反应的同一分歧。
memory_high_mb: auto 从 gateway 运行所在的 cgroup 限制推导预算(memory.high,然后 memory.max,然后 cgroup v1),无限制时回退到总 RAM。设一个数字以固定它,或 0/off 完全禁用该通道。辅助函数在 gateway/agent_cache_pressure.py。
缓存生命周期
Message arrives
│
▼
get_or_create_session() → session_key obtained
│
▼
Lookup _agent_cache[session_key]
│
├── Hit → move_to_end(), reuse AIAgent (preserves prompt cache)
│
└── Miss → create new AIAgent, store in cache
(if at capacity, popitem(last=False) evicts LRU entry)
│
▼
run_conversation() → agent processes message
│
▼
Housekeeping soft-releases idle agents without ending transcripts
清理流程
资源驱逐在软释放客户端之前移除缓存 agent 并提交记忆。完整的 _cleanup_agent_resources(agent) 拆解保留给真正的对话边界与关闭。
附录:关键配置
| 配置键 | 类型 | 默认 | 描述 |
|---|---|---|---|
group_sessions_per_user | bool | true | 按用户隔离群组/频道会话 |
thread_sessions_per_user | bool | false | 隔离线程会话 |
session_store_max_age_days | int | 0 | 清理 N 天前的会话(0=禁用) |
agent.gateway_auto_continue_freshness | int | 3600 | resume 新鲜窗口秒数 |
agent.gateway_timeout | int | 1800 | agent 轮次超时(默认 30 分钟) |
agent.agent_cache.max_size | int | 128 | 缓存 AIAgent 的 LRU 条目上限 |
agent.agent_cache.idle_ttl_secs | int | 3600 | 驱逐空闲这么久的 agent |
agent.agent_cache.memory_high_mb | int/str | auto | 超过该匿名 RSS 预算即丢弃 LRU 转录 |
agent.agent_cache.max_evictions_per_pass | int | 16 | 每次压力遍程丢弃的会话上限 |
agent.agent_cache.protect_recent | int | 8 | 压力遍程绝不触碰的 MRU 会话数 |
状态数据库与 FTS 恢复
规范转录位于 sessions 与 messages 表。FTS5 表及其同步触发器是派生索引,可以分离并重建而不删除规范消息。见状态 DB 恢复了解有界的活跃失败模式与显式修复流程。
对话生命周期
不支持空闲或每日重置设置。显式 /new 与 /reset、压缩、挂起与崩溃恢复保留各自独立的生命周期角色。