会话生命周期

读者: 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 上,用于路由、隔离与上下文注入。

字段

字段类型默认描述
platformPlatform(必填)标识消息平台的枚举(telegram、discord、slack、signal、whatsapp、matrix、local 等)。
chat_idstr(必填)平台级聊天/群组/频道标识符。经适配器的 chat_id_key 变换路由。
chat_nameOptional[str]None聊天或群组的可读名。
chat_typestr"dm""dm"、"group"、"channel"、"thread" 之一。控制会话键生成与隔离。
user_idOptional[str]None平台相关用户标识符。用于授权与按用户会话隔离。
user_nameOptional[str]None消息作者显示名。注入系统提示词。
thread_idOptional[str]None论坛话题 / Discord 线程 / Slack 线程标识符。区分线程化对话。
chat_topicOptional[str]None频道话题或描述(Discord 频道话题、Slack 频道用途)。
user_id_altOptional[str]None平台相关的稳定备选 ID(Signal UUID、飞书 union_id)。当 user_id 是临时的时使用。
chat_id_altOptional[str]NoneSignal 群组内部 ID——把 Signal 群组 V2 标识符映射到其规范形式。
is_botboolFalse消息作者是 bot 或 webhook 时为真(Discord bots)。
guild_idOptional[str]NoneDiscord guild / Slack workspace / Matrix 服务器范围标识符。
parent_chat_idOptional[str]None当 chat_id 指向线程时的父频道。
message_idOptional[str]None触发消息的 ID。用于置顶/回复/表情操作与 Discord ID 注入(注入的 [Triggering message id: …] 注记只搭在 API 绑定的消息上;持久化用户行保留所写文本)。
role_authorizedboolFalse当适配器经平台角色(而非单个用户 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_keystr(必填)标识对话车道的确定性键(见 §4)。
session_idstr(必填)这一次具体对话化身的唯一标识。格式:YYYYMMDD_HHMMSS_<8hex>。
created_atdatetime(必填)本次会话化身创建时间。
updated_atdatetime(必填)上次活动时间戳,用于资源整理。
originOptional[SessionSource]None创建本会话的来源,用于投递路由。
display_nameOptional[str]None聊天显示名(来自 SessionSource.chat_name)。
platformOptional[Platform]None持久化的平台枚举,用于跨重启路由。
chat_typestr"dm"聊天类型,也持久化用于策略查找。
input_tokensint0累计消耗的 LLM 输入(提示词)token。
output_tokensint0累计消耗的 LLM 输出(补全)token。
cache_read_tokensint0累计提示词缓存读取 token。
cache_write_tokensint0累计提示词缓存写入 token。
total_tokensint0跨所有轮次的 token 总数。
estimated_cost_usdfloat0.0估计的累计美元成本。
cost_statusstr"unknown"成本跟踪状态标签。
last_prompt_tokensint0上次 API 上报的提示词 token 数。用于准确的压缩预检。

布尔标志(状态机)

SessionEntry 有若干布尔标志,构成一个简单状态机,控制下次访问时的会话行为。

标志类型默认描述
was_auto_resetboolFalse当显式挂起导致替换会话时设置。也为历史记录保留。
auto_reset_reasonOptional[str]None显式挂起为 "suspended";旧行可能保留历史重置原因。
reset_had_activityboolFalse被替换的会话此前是否有活动。
is_fresh_resetboolFalse由显式 /new 或 /reset 设置。首条消息时触发话题/频道 skill 重新注入。与 was_auto_reset 区分,避免误导性的"session expired"提示。
expiry_finalizedboolFalse为恢复保留的历史 finalization 围栏;无计时器写它。
suspendedboolFalse硬强制擦除信号。由 /stop 或卡死循环升级(连续 3+ 次重启失败)设置。下次 get_or_create_session() 时,无视 resume_pending 强制新 session_id。
resume_pendingboolFalse软恢复标记。由 recover_interrupted_turns()(崩溃恢复一个被标记、未回复的轮次)或 drain 超时设置。下次访问时保留既有 session_id——用户在同一转录上继续。下一次成功轮次完成后清除。
resume_reasonOptional[str]None为何标记 resume:"restart_timeout"、"shutdown_timeout"、"restart_interrupted"。
last_resume_marked_atOptional[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() 中的优先级:

  1. suspended=True → 总是强制重置(硬擦除)
  2. resume_pending=True → 保留 session_id(软恢复)
  3. 无触发 → 返回既有条目(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 载入 _entries dict。
  • _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 的 DMagent:main:telegram:dm:12345
带 chat_id + 线程的 DMagent:main:telegram:dm:12345:thread_678
无 chat_id、带 participant_id 的 DMagent:main:signal:dm:user_abc
无 chat_id 也无 participant_id 的 DMagent: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:

  1. 它返回既有条目,不创建新 session_id。
  2. 既有转录完整加载。
  3. 标记不在此处清除——它保留到下一次成功轮次完成(run_conversation() 返回真实响应后由 gateway 调 clear_resume_pending())。
  4. 若恢复的轮次再次被中断,resume_pending 标志保持置位,下次重启会重试。卡死循环计数器处理终态升级(3 次重试 → 挂起)。

干净关闭标记(.clean_shutdown)

在优雅关闭末尾写入。下次启动时:

  • 若存在:完全跳过崩溃恢复并丢弃孤儿轮次标记。活跃 agent 已被排空,因此无会话卡死。
  • 然后删除该标记。

这防止 hermes update、hermes gateway restart 或 /restart 之后出现不想要的自动重置。


8. 消息排队流程

消息排队系统处理两种场景:

  1. 中断后续消息——当用户在 agent 处理期间发多条消息时,后续消息作为单槽待处理消息排队。
  2. /queue FIFO——显式 /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
  1. 从 config 收集已连接平台。
  2. 收集每个平台的主频道。
  3. 经 is_shared_multi_user_session() 判定 shared_multi_user_session。
  4. 若提供 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_userbooltrue按用户隔离群组/频道会话
thread_sessions_per_userboolfalse隔离线程会话
session_store_max_age_daysint0清理 N 天前的会话(0=禁用)
agent.gateway_auto_continue_freshnessint3600resume 新鲜窗口秒数
agent.gateway_timeoutint1800agent 轮次超时(默认 30 分钟)
agent.agent_cache.max_sizeint128缓存 AIAgent 的 LRU 条目上限
agent.agent_cache.idle_ttl_secsint3600驱逐空闲这么久的 agent
agent.agent_cache.memory_high_mbint/strauto超过该匿名 RSS 预算即丢弃 LRU 转录
agent.agent_cache.max_evictions_per_passint16每次压力遍程丢弃的会话上限
agent.agent_cache.protect_recentint8压力遍程绝不触碰的 MRU 会话数

状态数据库与 FTS 恢复

规范转录位于 sessions 与 messages 表。FTS5 表及其同步触发器是派生索引,可以分离并重建而不删除规范消息。见状态 DB 恢复了解有界的活跃失败模式与显式修复流程。

对话生命周期

不支持空闲或每日重置设置。显式 /new 与 /reset、压缩、挂起与崩溃恢复保留各自独立的生命周期角色。