跳到主要内容

11 · 会话生命周期

四系统的 session 模型:JSONL rollout、4 hook 事件、极简 id、多平台路由
同一个「持久化对话」的需求,四家落到完全不同的存储加触发加恢复抽象。

四家在 5 件 session 相关的事情上的覆盖:

维度 CodexClaude CodeOpenClawHermes
存储格式 JSONL rollout 文件加 SQLite 索引(~/.codex/sessions/rollout-{ts}-{uuid}.jsonl)multi-file(rollout、cost、attribution、file-history、todos、worktree 分别落盘)session store JSON(无强制 schema)gateway/session JSON 持久化加 platform-prefixed key
session 记录条目 5 种 RolloutItem:SessionMeta、ResponseItem、Compacted、TurnContext、EventMsg每个子系统自管:sessionMemory、sessionMemoryCompact、sessionStorage、sessionRestore只记 transcript-events 加 session-labelSessionSource 加 SessionContext 加 per-platform home channel
生命周期事件 Create、Resume 两条 RolloutRecorderParams 入口4 种 source:startup、resume、clear、compact 都走 hook由用户调用 spawnSubagent、switchSession 触发idle、daily、both、none 4 种 reset policy
resume 机制 ResumedHistory 重放 RolloutItem,从 SessionMeta 恢复 cwd、model、agent 元数据sessionRestore 恢复 7 类状态(cost、attribution、fileHistory、todos、model、worktree、systemPrompt)存的是 sessionId,下次启动按 id 找历史按 platform+chat_id 找历史,超过 idle 阈值自动 reset
多平台 / 多 thread ThreadId 对比 SessionId 区分,archived_sessions/ 子目录归档一个用户多 worktree、多 sub-agent、多 task,每个独立 sessionagent scope key 区分 default-agent、sub-agent4 平台加 signal 备用 ID 加 bluebubbles 备用,每个独立 session
session 这件事的工程化程度

§3 · 四家怎么实现 Session Lifecycle

Section titled “§3 · 四家怎么实现 Session Lifecycle”

Codex · 把 session 当数据库工程化:JSONL 持久化加 SQLite 索引加 Thread/Session ID 分离

Section titled “Codex · 把 session 当数据库工程化:JSONL 持久化加 SQLite 索引加 Thread/Session ID 分离”

Codex 在 session 这件事上的核心判断是:session 是 agent 最重要的状态载体,应该用数据库工程的标准去做,而不是当成 application state 随便存。这个判断带来三个互相关联的设计决策。

第一个决策是用 JSONL append-only 文件而不是单个 JSON 文件。这个选择背后是 agent 进程的现实:它可能被 IDE 杀掉、被 OOM killer 干掉、被用户 Ctrl-C、被 OS 重启,崩溃点随机分布在写入过程中的任何位置。如果用整个文件 JSON,一旦崩在写到一半的时候,整个文件就 corrupt 了不能 parse,下次启动 resume 直接失败,整个 session 丢光。JSONL 的设计是每行独立 parse:崩在第 N 行写到一半,丢的只是第 N 行,前面 N-1 行还能完整恢复,崩溃恢复能力从「全或无」变成「最多丢一条」。另一个好处是写性能:一个长 session 可能有几百轮,整文件 JSON 每轮都要把几 MB 的内容全量序列化再 atomic write,IO 压力随轮次线性上升。JSONL 每次只 append 一行(几 KB),一次 write syscall 就完事,Codex 实测一个几百 turn 的 session 单次写盘开销小于 5ms。还有第三个好处是流式消费:Codex 的 TUI 想实时显示「agent 在干什么」,JSONL 可以 tail -f 流式读,每个新行就是一个事件,整文件 JSON 完全做不到这点。

第二个决策是文件名格式:rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl。前缀是 ISO 时间戳,让列目录就是按时间排序。后缀是 UUID,防止同一秒内创建多个 session 时碰撞。中间用连字符分隔,让肉眼也能粗读时间。这种「文件名本身就编码所有路由信息」的设计让 session 管理工具几乎不用数据库就能跑:找最近的 session 就是按文件名排序取最后几个,找特定时间段就是文件名前缀匹配,归档老 session 就是把文件移到 archived_sessions/ 子目录。

第三个决策是每个 session 的第一行强制写 SessionMeta:

Codex codex/codex-rs/rollout/src/recorder.rs:80-105 — RolloutRecorder 接收 Create、Resume 两种入参,JSONL 落盘并通过 mpsc 异步写
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
/// every update.
#[derive(Clone)]
pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
writer_task: Arc<RolloutWriterTask>,
pub(crate) rollout_path: PathBuf,
event_persistence_mode: EventPersistenceMode,
}
#[derive(Clone)]
pub enum RolloutRecorderParams {
Create {
conversation_id: ThreadId,
forked_from_id: Option<ThreadId>,
source: SessionSource,
thread_source: Option<ThreadSource>,
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
event_persistence_mode: EventPersistenceMode,
},
Resume {
// ...
},
}

这个 SessionMeta 包含 resume 时必须的所有元数据:

Codex codex/codex-rs/rollout/src/metadata.rs:39-65 — 从 SessionMeta 还原 ThreadMetadataBuilder,包括 cwd / model / agent / git 信息
pub(crate) fn builder_from_session_meta(
session_meta: &SessionMetaLine,
rollout_path: &Path,
) -> Option<ThreadMetadataBuilder> {
let created_at = parse_timestamp_to_utc(session_meta.meta.timestamp.as_str())?;
let mut builder = ThreadMetadataBuilder::new(
session_meta.meta.id,
rollout_path.to_path_buf(),
created_at,
session_meta.meta.source.clone(),
);
builder.model_provider = session_meta.meta.model_provider.clone();
builder.agent_nickname = session_meta.meta.agent_nickname.clone();
builder.agent_role = session_meta.meta.agent_role.clone();
builder.agent_path = session_meta.meta.agent_path.clone();
builder.cwd = session_meta.meta.cwd.clone();
builder.cli_version = Some(session_meta.meta.cli_version.clone());
builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
builder.approval_mode = AskForApproval::OnRequest;
if let Some(git) = session_meta.git.as_ref() {
builder.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone());
builder.git_branch = git.branch.clone();
builder.git_origin_url = git.repository_url.clone();
}
Some(builder)
}

Resume 时为什么需要这么多元数据?因为单纯重放消息历史是不够的:模型看到一条「请把 foo.py 里的 bar 函数改成异步」消息时,它需要知道当时的工作目录在哪(不然找不到 foo.py)、当时跑的是哪个模型(不同模型表现不同,混着复读会让对话不连贯)、当时的 approval mode 是什么(之前是 --accept-edits,resume 后变 interactive 会卡在每个 edit)、当时的 git commit 是哪个(agent 之前基于某个 commit 推理,如果 resume 时已经在不同 branch 上,agent 给的建议就会错)。这些隐藏前提如果不恢复,agent 就会表现得「换了个人」,用户会觉得 resume 功能没用。

第四个工程决策是把 session 数量增长时的性能问题前置考虑:当 session 累积到几百几千个时,每次启动列目录扫所有 JSONL 第一行解析出 SessionMeta 会变得很慢(IO 开销加 JSON parse 开销)。Codex 在 JSONL 之外再维护一个 SQLite state.db 做线程索引:每个 thread 的元数据(cwd、model、created_at、last_message_at、archived 标记)落进表里,列 thread 直接 SQL 查询,毫秒级响应。JSONL 文件依然是真相之源(SQLite 损坏了可以从 JSONL 重建),但日常查询走 SQLite。启动时不会扫所有文件,只在 SQLite 找不到对应 thread_id 时才 backfill 一次。这种「文件作为持久层加数据库作为索引层」的双层设计是数据库系统的标准做法。

第五个工程决策是把「逻辑对话」和「具体运行实例」拆成两个独立 ID。ThreadId 是逻辑对话单位:用户说「我那个关于 refactoring 的对话」指的是 thread,thread 可以跨多次进程启动存在、可以被 fork(基于历史开新分支)、可以被 archive(归档到 archived_sessions/)。SessionId 是一次具体的运行实例:进程启动一次就是一个 session,跟进程同生命周期,用户不关心也看不到具体的 session_id。一个 thread 可能跨多个 session(每次 resume 是新 session,但继承同一个 thread_id)。这种拆分让用户视角(持久 thread)和系统视角(瞬态 session)各自独立演化,避免混淆。

Session 在内存里的运行时结构是一个带锁的状态机:

Codex codex/codex-rs/core/src/session/session.rs:11-37 — Session 是一个带锁的状态机:state Mutex 加 active_turn Mutex 加 Mailbox 加服务集合
/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
pub(crate) struct Session {
pub(crate) conversation_id: ThreadId,
pub(crate) installation_id: String,
pub(super) tx_event: Sender<Event>,
pub(super) agent_status: watch::Sender<AgentStatus>,
pub(super) out_of_band_elicitation_paused: watch::Sender<bool>,
pub(super) state: Mutex<SessionState>,
pub(super) managed_network_proxy_refresh_lock: Semaphore,
pub(super) features: ManagedFeatures,
pub(super) pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
pub(super) mailbox: Mailbox,
pub(super) mailbox_rx: Mutex<MailboxReceiver>,
pub(super) idle_pending_input: Mutex<Vec<ResponseInputItem>>,
pub(crate) goal_runtime: GoalRuntimeState,
pub(crate) guardian_review_session: GuardianReviewSessionManager,
pub(crate) services: SessionServices,
pub(super) next_internal_sub_id: AtomicU64,
}

注释里这句「A session has at most 1 running task at a time, and can be interrupted by user input」是核心约束:一个 session 同时只能跑一个 turn,用户输入可以打断当前 turn,但不能让两个 turn 并发跑。这个约束防止 race condition:如果两个 turn 同时往 message history 里写东西、同时调用工具、同时改文件,状态会乱套。代价是单 session 不能并行处理多个用户请求,但 Codex 用更激进的策略弥补:用户想并行就开新 thread(fork 当前 thread),新 thread 是独立 session 跑独立 turn,互不干扰。

Claude Code · 把 session 拆成 22 个子系统加 4 种生命周期事件触发 hook

Section titled “Claude Code · 把 session 拆成 22 个子系统加 4 种生命周期事件触发 hook”

Claude Code 在 session 上做的事情比 Codex 更细,但思路完全不同。它的核心判断是:session 不是一个东西,而是一组互相独立的子系统的集合(消息历史是一个子系统、cost tracker 是一个、attribution 是一个、todo list 是一个、worktree state 是一个、file history 是一个等等),每个子系统有自己的存储格式、自己的生命周期、自己的恢复逻辑。所以 Claude Code 把 session 拆成了 22 个文件,每个名字里带 session 关键字:sessionStart 管启动、sessionRestore 管恢复、sessionStorage 管持久化、sessionState 管运行时状态、sessionMemory 管内存中的 message 缓冲、sessionMemoryCompact 管上下文压缩、sessionRunner 管 turn 执行、sessionIngress 管入口(IDE、CLI 等不同接入点)、sessionEnvVars 管环境变量、sessionEnvironment 管运行环境、sessionActivity 管活动检测(idle 判定)、sessionHistory 管历史日志、sessionFileAccessHooks 管文件访问钩子、sessionHooks 管生命周期 hook 注册、sessionTracing 管 OTEL 追踪、sessionUrl 管 IDE 跳转 URL、sessionTitle 管显示标题、sessionIngressAuth 管 ingress 鉴权、sessionIdCompat 管旧版 ID 兼容、sessionStoragePortable 管跨设备存储、SessionsWebSocket 管 IDE WebSocket。

这种拆法的好处是每个子系统可以独立演化:加新功能(比如「记录用户用过的 skill」)只需要新增一个 sessionSkillUsage 子系统,不会动其他文件。坏处是 resume 时变得复杂:要把 22 个子系统的状态按正确顺序全部恢复,错一步 agent 就会表现失常。

核心抽象是把所有 session 生命周期事件归纳成 4 种 source,每种 source 触发一组 plugin hook 加 user hook:

Claude Code claude-code/src/utils/sessionStart.ts:34-66 — processSessionStartHooks 4 种 source:startup、resume、clear、compact
// Note to CLAUDE: do not add ANY "warmup" logic. It is **CRITICAL** that you do not add extra work on startup.
export async function processSessionStartHooks(
source: 'startup' | 'resume' | 'clear' | 'compact',
{
sessionId,
agentType,
model,
forceSyncExecution,
}: SessionStartHooksOptions = {},
): Promise<HookResultMessage[]> {
// --bare skips all hooks. executeHooks already early-returns under --bare
// (hooks.ts:1861), but this skips the loadPluginHooks() await below too —
// no point loading plugin hooks that'll never run.
if (isBareMode()) {
return []
}
const hookMessages: HookResultMessage[] = []
const additionalContexts: string[] = []
const allWatchPaths: string[] = []
// Skip loading plugin hooks if restricted to managed hooks only
// Plugin hooks are untrusted external code that should be blocked by policy
if (shouldAllowManagedHooksOnly()) {
logForDebugging('Skipping plugin hooks - allowManagedHooksOnly is enabled')
} else {
// Ensure plugin hooks are loaded before executing SessionStart hooks.
// ...
try {
await withDiagnosticsTiming('load_plugin_hooks', () => loadPluginHooks())
} catch (error) {
// Log error but don't crash - continue with session start without plugin hooks

这 4 种 source 表达 4 种语义完全不同的场景。startup 是「全新对话」:用户第一次输入 claude 启动,没有任何历史,hook 应该做的事是加载 CLAUDE.md 项目说明、设置工作目录、初始化 cost tracker、按 plugin 配置注入 system prompt 的某些 section。不应该做的是从 archive 拉历史(根本没有)、恢复 worktree state(用户没要 worktree)。resume 是「从历史 session 继续」:用户输入 claude --resume 选了一个历史会话,hook 应该恢复 cost state、attribution snapshot、file history、todos、model override、worktree state。不应该重置 cost tracker(resume 是要继续不是从零开始)、不应该重新加载 CLAUDE.md(已经在 history 里了)。clear 是「用户主动 /clear」:在对话过程中用户想重置上下文但保留 session 元数据,hook 应该清掉 message history、保留 cost tracker(计费不该清)、保留 model override(用户偏好不变)。不应该清掉 session 文件(用户可能后面 resume)。compact 是「上下文超阈值触发压缩」:系统判断 context tokens 大于 limit 触发 compact subagent,hook 应该 snapshot 关键信息(避免压缩后丢失)、暂停 cost tracker 写入(compact 自己 LLM 调用的计费要分离)。不应该清 message history(compact 是「精简」不是「丢弃」)。

把这 4 种合并是诱惑很大的:startup 和 resume 都是「开始 session」,clear 和 compact 都是「中途事件」,看起来合并成 2 种更简洁。但实际上每种 source 下「该做什么、不该做什么」完全不同,合并之后 hook 就得在每个分支里手写 if-else 判断当前情况,反而比分成 4 种 source 更繁琐。Claude Code 实测发现 4 种是「最小够用」的颗粒:每种 source 都有清晰的 do、don’t 列表,hook 写作更精确。

代码注释里有一行关键的铁律值得单独拎出来讲:「do not add ANY “warmup” logic. It is CRITICAL that you do not add extra work on startup.」 直译是「不要加任何热身逻辑,启动时不要做额外工作,这一点至关重要」。这条铁律的来源是反复踩坑的经验:Claude Code 2.0 之前曾经有过这些 warmup:startup 时扫 ~/.claude 目录准备 quick-resume 列表(3 秒)、startup 时加载所有 plugin 避免后续 lazy load 延迟(5 秒)、startup 时跑 git status 预填 context(1 到 3 秒)、startup 时 fetch latest version 检查更新(2 秒)。每一个单独看都合理(每个 PR 增加 1 到 3 秒,没有 reviewer 会反对),一年下来 startup 从 2 秒变成 11 秒。CLI 工具启动超过 200ms 用户就会感到「卡」,11 秒已经完全脱离了「响应式工具」的产品定位。痛过之后才在代码注释里写下这条铁律,让任何新 PR 看到这条都得解释「我这个工作为什么不能 lazy」。

resume 时不是简单重载 JSONL 就完事,要按顺序恢复 7 类状态:

Claude Code claude-code/src/utils/sessionRestore.ts:1-58 — sessionRestore 跨 7 个子系统:cost、attribution、fileHistory、todos、model、worktree、systemPrompt
import { feature } from 'bun:bundle'
import type { UUID } from 'crypto'
import { dirname } from 'path'
import {
getMainLoopModelOverride,
getSessionId,
setMainLoopModelOverride,
setMainThreadAgentType,
setOriginalCwd,
switchSession,
} from '../bootstrap/state.js'
import { clearSystemPromptSections } from '../constants/systemPromptSections.js'
import { restoreCostStateForSession } from '../cost-tracker.js'
import type { AppState } from '../state/AppState.js'
import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'
import {
type AgentDefinition,
type AgentDefinitionsResult,
getActiveAgentsFromList,
getAgentDefinitionsWithOverrides,
} from '../tools/AgentTool/loadAgentsDir.js'
import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js'
import { asSessionId } from '../types/ids.js'
import type {
AttributionSnapshotMessage,
ContextCollapseCommitEntry,
ContextCollapseSnapshotEntry,
PersistedWorktreeSession,
} from '../types/logs.js'
import type { Message } from '../types/message.js'
import { renameRecordingForSession } from './asciicast.js'
import { clearMemoryFileCaches } from './claudemd.js'
import {
type AttributionState,
attributionRestoreStateFromLog,
restoreAttributionStateFromSnapshots,
} from './commitAttribution.js'
import { updateSessionName } from './concurrentSessions.js'
import { getCwd } from './cwd.js'

每个 import 都对应一类必须恢复的状态:cost-tracker 是花了多少钱、commitAttribution 是哪些改动算用户写的哪些算 agent 写的、AgentTool/agentColorManager 是子 agent 的颜色编码、TodoWriteTool 是待办清单、AppState 是应用级别的 UI 状态、claudemd 缓存清理、worktree-related types 是 git worktree session 状态。少恢复一个,agent 就会在某个维度上表现不一致:比如 cost 没恢复,用户会看到「从零开始计费」的错觉。attribution 没恢复,git commit 上的「Co-authored-by Claude」会缺。todos 没恢复,用户上次提的待办事项被忘了。worktree state 没恢复,agent 不知道自己应该在哪个 worktree 操作。这种「resume 复杂度」是 IDE 级 session 的代价:状态被故意分散到多个子系统让每个子系统独立演化,恢复时就得跨子系统编排。

OpenClaw · 把 session 退化成一个身份概念:只校验 ID,存储交给上层

Section titled “OpenClaw · 把 session 退化成一个身份概念:只校验 ID,存储交给上层”

OpenClaw 在 session 这件事上做得最少,少到看代码会让人怀疑是不是漏写了。整个 src/sessions/ 目录只有 12 个文件,核心代码加起来不到 100 行:

OpenClaw openclaw/src/sessions/session-id.ts:1-6 — session id 就是一个 UUID 正则校验
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function looksLikeSessionId(value: string): boolean {
return SESSION_ID_RE.test(value.trim());
}

就这么一个正则加一个 helper 函数。整个 session 模块剩下的文件也都是这种「轻量工具」级别:session-key-utils.ts 负责把 agent scope 拼到 session key 上({agentId}:{sessionId} 格式,让同一个 session 在不同 agent 视角下可以区分)、session-label.ts 负责生成人类可读的 label(用于 UI 显示)、transcript-events.ts 负责会话 transcript 的事件序列化、model-overrides.tslevel-overrides.ts 提供 per-session 的配置覆盖、send-policy.ts 管消息发送策略。整个模块没有任何 rollout 文件、没有 SQLite 索引、没有 lifecycle hook,连 session 应该存哪里都不规定。

这种极简看起来像「没做完」,但是 OpenClaw 定位决定的。OpenClaw 是 framework(agent 平台),不是 product(agent 产品):它的用户是写 agent 的开发者,不是用 agent 的终端用户。这两种用户对 session 的需求完全不同:终端用户需要「列出过去 7 天的会话」「resume 任意 session」「自动 archive 旧 session」这些产品功能,开发者却各自有自己的存储栈:写 Slack bot 的人要把 session 存到 Slack thread、写 IDE 插件的人要存到 IDE workspace state、写 SaaS 的人要存到 PostgreSQL、Redis、写 CLI 工具的人要存本地 JSONL。如果 OpenClaw 在框架层强制一种存储方案,所有这些场景都会被绑死。所以 OpenClaw 选择只校验 ID 格式合规、只提供 session_key 的命名空间工具、只提供 transcript 事件的序列化格式,把「存哪里、什么时候 reset、怎么 resume」全部留给上层调用方。

这是一种「定义 contract,不定义 implementation」的框架设计哲学:类似数据库的 query layer 不该决定数据存在哪里(SQLite、PostgreSQL、MySQL 的存储后端可以变,但 query layer 一致)。代价是 out-of-box 不够好(要 demo 一个完整 agent 要先选个 session backend)、生态可能分裂(不同 plugin 存到不同地方)。好处是 OpenClaw 可以适配任何部署形态而不需要改框架代码。

Hermes · 为多平台聊天而生:SessionSource 记录消息从哪来加 4 种 reset 模式

Section titled “Hermes · 为多平台聊天而生:SessionSource 记录消息从哪来加 4 种 reset 模式”

Hermes 在 session 上的设计跟前三家都不一样,因为它要面对的现实最复杂:一个 Hermes agent 同时服务 6 个以上的 messaging platform(Telegram、Slack、Discord、WhatsApp、Signal、BlueBubbles)外加 CLI 和 Webhook,一个用户可能在 Telegram 私聊里跟 agent 说一件事、在 Slack 工作群里跟 agent 说另一件事、在 Discord 服务器某个 channel 里跟 agent 说第三件事。这些对话在用户感受里是「跟同一个 agent 的不同子线程」,但在系统层面必须是完全独立的 session:不能让 Telegram 私聊的内容混进 Slack 工作群(隐私和合规问题),也不能让 Discord 公开 channel 的内容污染 Telegram 私聊(context 混乱)。

所以 Hermes 的 session 设计有两个核心抽象。第一个是 SessionSource,记录每条消息「从哪里来」:

Hermes hermes-agent/gateway/session.py:65-106 — SessionSource 等于 platform 加 chat_id 加 user、chat 元数据,覆盖 DM、group、channel、thread 4 种聊天类型
@dataclass
class SessionSource:
"""
Describes where a message originated from.
This information is used to:
1. Route responses back to the right place
2. Inject context into the system prompt
3. Track origin for cron job delivery
"""
platform: Platform
chat_id: str
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
chat_topic: Optional[str] = None
user_id_alt: Optional[str] = None # Signal UUID
chat_id_alt: Optional[str] = None # Signal group internal ID
is_bot: bool = False
@property
def description(self) -> str:
"""Human-readable description of the source."""
if self.platform == Platform.LOCAL:
return "CLI terminal"
# ...

这个数据类要回答三个问题:消息走哪个 routing 路径回去(platform 加 chat_id 决定回复发到哪)、要给 system prompt 注入什么上下文(让 agent 知道「现在你在 Slack 工作群里,应该用更专业的语气;现在你在 Telegram 私聊里,可以更随意」)、cron 任务的输出该投递到哪里(用户问「明早 8 点提醒我开会」,第二天 8 点 agent 主动发消息要发到对的平台对的 chat)。注意 chat_type 字段覆盖了 4 种聊天形态:dm(私聊)、group(普通群)、channel(公开频道)、thread(线程化对话),不同 chat_type 下 agent 的行为应该不同:dm 里可以畅所欲言,group 里要克制不要刷屏,channel 里要更正式。还有 Signal 特有的 user_id_altchat_id_alt 字段:Signal 协议在群组里用电话号码做用户 ID 但有时候又用 UUID,必须两个都存才能正确路由。

第二个核心抽象是 SessionResetPolicy 提供的 4 种 reset 模式:

Hermes hermes-agent/gateway/config.py:100-141 — SessionResetPolicy 4 模式:daily、idle、both、none,可按 platform、chat_type 覆盖
@dataclass
class SessionResetPolicy:
"""
Controls when sessions reset (lose context).
Modes:
- "daily": Reset at a specific hour each day
- "idle": Reset after N minutes of inactivity
- "both": Whichever triggers first (daily boundary OR idle timeout)
- "none": Never auto-reset (context managed only by compression)
"""
mode: str = "both" # "daily", "idle", "both", or "none"
at_hour: int = 4 # Hour for daily reset (0-23, local time)
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
notify: bool = True # Send a notification to the user when auto-reset occurs
notify_exclude_platforms: tuple = ("api_server", "webhook")

这 4 种模式对应 4 类真实用户群。daily 是「每天定时 reset」:典型用户是用 agent 当个人助理的人,作息规律,每天用一段时间晚上不用,凌晨 4 点(默认 at_hour)自动 reset 让第二天从干净状态开始。好处是每天一个清爽 session 不积累冗余上下文,坏处是熬夜用户可能 4 点突然失忆。idle 是「闲置超时 reset」:典型用户是项目协作场景,跟 agent 在 Slack 工作群讨论一个项目可能几天才回一次消息,但只要还在讨论就保持上下文。好处是以「有活动」为颗粒,连续讨论同一个项目时 context 持续保留,坏处是超过 idle 阈值后强制 reset 可能让用户体验不佳。both 是「任一触发就 reset」:这是 Hermes 默认模式,既有每天清盘的稳定性又有工作日连续的连贯性,多数场景都合适。none 是「永不 auto-reset」:典型用户是维护长期项目的人(小说创作、知识库整理),context 永远保留 agent 还「记得」用户在干什么。代价是 context 会无限增长所以必须依赖 compact 机制兜底。

notify_exclude_platforms 字段是个很实用的细节:reset 时默认会给用户发通知(「agent 已重置上下文」),让用户知道为什么 agent 突然不记得之前的事。但对 api_server 和 webhook 这种程序调用方,发通知毫无意义反而是噪音,所以默认排除这两个平台。

Hermes 还有一个其他三家都没有的独有设计:PII(个人身份信息)按平台脱敏:

Hermes hermes-agent/gateway/session.py:176-209 — 安全平台清单加按需 PII redaction:发给 LLM 前替换手机号、用户 ID 为 hash
_PII_SAFE_PLATFORMS = frozenset({
Platform.WHATSAPP,
Platform.SIGNAL,
Platform.TELEGRAM,
Platform.BLUEBUBBLES,
})
"""Platforms where user IDs can be safely redacted (no in-message mention system
that requires raw IDs). Discord is excluded because mentions use ``<@user_id>``
and the LLM needs the real ID to tag users."""
def build_session_context_prompt(
context: SessionContext,
*,
redact_pii: bool = False,
) -> str:
"""
Build the dynamic system prompt section that tells the agent about its context.
This is injected into the system prompt so the agent knows:
- Where messages are coming from
- What platforms are connected
- Where it can deliver scheduled task outputs
When *redact_pii* is True **and** the source platform is in
``_PII_SAFE_PLATFORMS``, phone numbers are stripped and user/chat IDs
are replaced with deterministic hashes before being sent to the LLM.
Platforms like Discord are excluded because mentions need real IDs.
Routing still uses the original values (they stay in SessionSource).
"""

注释里把「为什么 Discord 不能 redact」讲得很清楚:Discord 的 mention 语法是 <@user_id>(必须用数字 ID),Slack 的 mention 语法是 <@U12345678>(必须用 Slack member ID),这两个平台如果把 user_id 替换成 hash 发给 LLM,LLM 生成回复时就没办法正确 mention 用户了,agent 跟用户的「我在跟你说话」的产品信号会丢失。而 WhatsApp、Signal、Telegram、BlueBubbles 这几个平台的 mention 都用自然语言(@用户名、电话号码),不依赖内部 ID,所以可以放心 redact。这是产品需求和安全需求两端拉锯后落地的具体结论,不是抽象设计:把「为什么这样」写进代码注释里是一种很值得学习的工程透明度。

整个 PII 系统的关键设计是「路由路径和 LLM 路径分离」:SessionSource 永远保留原始 user_id 和 chat_id 用于路由(Hermes 系统层知道真实 ID 才能把回复发到正确的地方),但发给 LLM 的 prompt 里这些 ID 被替换成确定性 hash(hash_user_001、hash_chat_001)。LLM 生成回复时引用 hash_user_001,Hermes 把回复路由出去之前把 hash 映射回真实 user_id 再发送。这种「LLM 不知道真实 ID 但系统知道」的设计在企业部署场景很有用。

§4 · 四家在 session 上的共同认知

Section titled “§4 · 四家在 session 上的共同认知”

虽然四家在 session 的实现深度差异极大,但有 3 件事是不可绕过的工程共识,每家都用自己的方式承认了。

第一件是每个 session 必须有全局唯一的 ID。Codex 用 UUIDv4(128 位足够防碰撞)、Claude Code 用 UUIDv4 加上 worktree 维度的额外限定、OpenClaw 严格校验 UUID 格式、Hermes 用 platform+chat_id 组合作为天然唯一标识。为什么必须唯一?因为 session 数据要持久化到文件系统、数据库、远端 KV,碰撞会导致一个 session 的数据覆盖另一个,bug 极难排查。另外多个 session 可能在不同进程或不同设备上同时活着,没有全局唯一 ID 就没法正确 routing。

第二件是 session 必须跟 user/scope 绑定,不能是全局的。Codex 把 agent_path 和 agent_nickname 写进 SessionMeta(同一台机器上 Codex 给不同 agent role 用的 session 要能区分)、Claude Code 用 worktreeSession 让 git worktree 各自有独立 session(一个项目开多个 worktree 同时工作时 session 不能混)、OpenClaw 用 {agentId}:{sessionId} 拼接 key(同一个 sessionId 在不同 agent 视角下完全隔离)、Hermes 用 platform+chat_id 自然实现按平台和聊天上下文分隔。这个原则的反面是「全局 session pool」:如果所有用户、所有 agent、所有项目共享一个 session 集合,那 session 之间会互相污染,agent 在用户 A 那里学到的偏好会被错误地应用到用户 B 上。

第三件是 resume 不能只重放消息历史,必须把隐藏的状态也恢复。这是 resume 这件事看似简单但很容易做错的地方。最容易踩的坑是:开发者实现 resume 时想着「把 messages 列表加载回来塞给模型就行了」,结果发现 agent resume 后表现得「像换了个人」:因为模型读对话历史时虽然能看到「用户让我改 foo.py」这类内容,但不知道当时的 cwd 在哪(结果找不到 foo.py 报错)、不知道当时的 approval mode 是什么(结果在每个 edit 上卡住等审批,而原本是 auto-accept)、不知道当时的 git state 是什么(基于错误的 commit 推理)。所以四家都强制在 session 元数据里保存这些「隐藏前提」:Codex 的 SessionMeta 第一行写 cwd、model、git_sha、agent_role、cli_version、sandbox_policy、approval_mode,Claude Code 的 sessionRestore 跨 7 个子系统恢复状态,OpenClaw 让上层调用方决定但提供 transcript_events 序列化框架,Hermes 把 SessionContext 重新构建。

§5 · 四家在 session 上的关键分歧

Section titled “§5 · 四家在 session 上的关键分歧”
四家 session 模型在存储工程化深度加生命周期颗粒度上的相对位置
OpenClaw 极简 id 最左下,Codex JSONL 加 SQLite 偏右中,Claude Code 22 工具加 4 hook 最右上,Hermes 多平台路由居中偏上。

虽然共同点确立了 session 设计的基本盘,但四家在「session 应该做多深」这件事上的分歧才是真正决定他们各自适合什么场景的核心。换一个角度看,「想做什么样的 agent」决定了你应该参考哪家的实现。

如果想做一个长期使用的开发者工具型 agent,用户希望能列出过去几个月所有的对话历史、能 resume 任意一次会话、能给 session 命名归档分类,那 Codex 的 JSONL 加 SQLite 双层架构是正确选择。 这种场景的核心需求是「持久化完整、性能可控、跨时间引用」,Codex 的设计每一条都对得上:JSONL 给崩溃恢复能力、SQLite 索引给毫秒级查询、ThreadId 给跨进程的稳定身份、archived_sessions/ 子目录给老 session 归档。代价是工程复杂度高(要维护 JSONL 格式加 SQLite schema 加两者一致性),但对一个想做长期产品的开发者工具,这个代价是值得付的。

如果想做一个 IDE 集成的 agent,需要跟 cost tracker、file change tracking、todo list、worktree 这些 IDE 一等公民子系统深度协作,那 Claude Code 的 22 文件分层加 4 种 lifecycle source 是正确选择。 这种场景的核心需求是「让 session 跟 IDE 的其他状态系统配合而不是替代它们」,Claude Code 的设计每个子系统独立演化、4 种 source hook 让 plugin 精确选择什么时候介入、warmup 禁令保证启动延迟可控。代价是 22 个文件长期维护成本高、sessionRestore 7 类状态的恢复顺序敏感(加新状态容易踩坑),但对 IDE 级 agent 这种代价是不可避免的。

如果想做一个 agent 框架而不是 agent 产品,不希望强加存储方案给用户,那 OpenClaw 的极简 session-id 是正确选择。 这种场景的核心需求是「定义清晰的 contract,把 implementation 留给用户」,OpenClaw 只校验 UUID 格式加提供 session_key 命名空间加提供 transcript 序列化,剩下的全交给上层。代价是 out-of-box 体验差(用户上手要先选个 backend),但对框架定位的产品这是合理取舍。

如果想做一个多平台聊天 agent,要同时服务 Telegram、Slack、Discord 等多个平台,那 Hermes 的 SessionSource 加 4 种 reset 模式加 PII 安全平台清单是正确选择。 这种场景的核心需求是「精确建模消息从哪来、按平台和场景定制 reset 策略、按平台能力区分能不能脱敏」,Hermes 的设计每一条都贴合:SessionSource 编码消息来源的 4 种 chat_type、SessionResetPolicy 提供 4 种 reset 模式可 per-platform 覆盖、_PII_SAFE_PLATFORMS 按 mention 语法精确划分。代价是 6 个以上平台兼容性自己管、reset 策略和 compact 之间的边界要写清楚,但对多平台 chat agent 这是必需的复杂度。

系统评分亮点风险
Codex★★★★★5 种 RolloutItem 全场景覆盖。JSONL 加 SQLite 双层(文件持久加索引快)。ThreadId、SessionId 概念分离。archived_sessions/ 归档区独立。Session struct 一锁定终身(at most 1 turn at a time)SQLite 索引 schema 演化要小心。JSONL 文件多了之后列目录慢,要靠 state DB
Claude Code★★★★★4 种 source hook(startup、resume、clear、compact)颗粒度最细。22 个 session 工具分层清晰。warmup 禁令直接写在注释里。plugin hook 跟 user hook 分开走 trust 边界22 文件要长期维护。sessionRestore 7 类状态恢复顺序敏感,加新状态容易踩坑
OpenClaw★★★session-id 一个正则就完事。agent scope key 拼接清晰。让上层调用方决定怎么存,灵活性最高没有 lifecycle hook 概念,插件想在 startup、resume 做事得自己接 hook。resume 语义由调用方拼
Hermes★★★★多平台路由是同类最完整。SessionResetPolicy 4 模式实用主义。PII redaction 安全平台清单按 mention 需求精确划分。reset 通知 exclude api_server、webhook 体现真实场景理解6 个以上平台兼容性自己管。reset 策略与 compact 之间的边界要写清楚。session 持久化跟 plugin memory 之间容易重复
评分依据:持久化完整度 加 生命周期事件颗粒度 加 业务场景贴合度

§7 · 自己实现 Session 生命周期的最佳实践

Section titled “§7 · 自己实现 Session 生命周期的最佳实践”

下面是从四家提炼的「自己写 session 系统」配方。先把基础四件套打牢,再加生产级特性,最后避开五个常见死路。

复刻方案

最小可行

  • session_id 用 UUID v4(参考 OpenClaw 的正则校验):UUID v4 全局唯一不会撞,且加个正则校验就能防恶意调用方传脏 ID
  • 存一个 JSONL 文件(参考 Codex 格式:rollout-{ts}-{uuid}.jsonl):JSONL 一行一个事件 append-only 写就行,崩溃时只丢最后一行其他都安全。时间戳前缀方便人肉排序
  • session 第一行写 SessionMeta:cwd、model、agent、git_sha、timestamp。这是 resume 时恢复运行环境的唯一来源,模型不需要看(meta 是给 harness 看的)
  • resume 时按 session_id 找 JSONL,从 SessionMeta 恢复 cwd、model:不只是恢复 messages,环境(工作目录、模型选择、approval mode)也要跟着回到当时。不然 agent 会「失忆」

进阶

  • JSONL 之外加 SQLite state DB 做线程索引(参考 Codex):直接扫文件 list session 的 cwd、git、model 太慢(一万个 session 要扫 1 分钟),SQLite 索引一下毫秒返回
  • 区分 ThreadId 对比 SessionId(参考 Codex):thread 是逻辑对话(用户视角的「这次聊天」),session 是具体运行实例(一次启动加退出的物理周期)。resume 时同一 thread 可对应多个 session
  • 4 种 lifecycle source(参考 Claude Code):startup、resume、clear、compact,各自触发 hook。不同生命周期事件需要不同处理(startup 加载用户偏好、resume 恢复 cwd、clear 清空 message、compact 压缩历史)
  • sessionRestore 跨子系统恢复:不只 messages,还要恢复 cost(继续累加而非清零)、attribution(哪些操作是这个用户的)、file history(编辑历史)、todos(任务清单)、worktree(git 分支)、model(模型选择)。任何一项漏恢复就「断片」
  • archived_sessions/ 独立子目录归档历史 session(参考 Codex):当前 session 跟历史 session 物理分开,list current 时不扫历史。archive 既能控制文件数量也能保留历史可查
  • 多平台路由用 SessionSource(参考 Hermes 的 platform 加 chat_id):routing 信息跟 LLM 输入分离(不让模型看到「我在 telegram 还是 slack」)。同一会话跨平台时知道当时在哪平台
  • SessionResetPolicy 4 模式(参考 Hermes):daily(每日重置)、idle(空闲 30min 重置)、both(两个条件之一触发)、none(永不自动重置),可 per-platform 覆盖。不同场景需要不同策略(客服 daily、长跑助理 none)
  • PII redaction 看 platform 能力(参考 Hermes 的 _PII_SAFE_PLATFORMS):mention-based 平台不能 redact(@Alice 改成 [REDACTED] 用户就找不到 Alice 了),name-based 平台可以 redact。安全策略要按平台特性调
  • 在注释里禁止 warmup(参考 Claude Code 的 do not add warmup):启动路径上的额外工作长期会失控(每加一个 warmup 启动慢 100ms,10 个就慢 1s)。用户启动慢就跑到隔壁工具去了

一开始别做

  • 别把 session 元数据塞 message history 里:模型 resume 时看到一堆 meta 信息会困惑,meta 应该走 SessionMeta(独立字段)。混在一起既污染 prompt 又难做单独修改
  • 别用单个 JSON 文件存整个 session:每次写要全量序列化(10MB session 每次都要重写),崩了丢全部(最坏情况丢整个对话历史)。JSONL append-only 一行一个事件更安全
  • 别假设 session_id 一定唯一:恶意调用方可能传重复 ID 试图覆盖别人 session。正则校验加数据库唯一约束都要
  • 别让 resume 只重放 messages:cwd、model、approval mode 不恢复,agent 会「失忆」(用户问「你刚才在哪个目录?」答不上来)
  • 别在 startup hook 里做网络、文件大扫描:Claude Code 的「do not add warmup」是反复踩坑的结论。启动慢用户体验毁,warmup 都该走 lazy loading 而非 startup
四种 session 模型流程并列对照
Codex JSONL 加 SQLite 持久化,Claude Code 4 hook 加 22 工具子系统分层,OpenClaw 极简 id,Hermes 多平台路由加 4 reset 模式。

把 4 种放一起,工程化方向的差异一眼可见:文件级持久化(Codex)走 子系统分层(Claude Code)走 极简 ID(OpenClaw)走 多平台路由(Hermes)。

  1. 🟢 写 SessionMeta:定义一个结构记录 session 启动元数据:cwd、model、git_sha、agent_role、timestamp。会话开始时落 JSONL 第一行。
  2. 🟠 加 SQLite 索引:session 数量超过 100 时按时间扫目录会很慢。写一个 sqlite 表存 thread_id / cwd / timestamp / last_message_at,启动时按需 backfill。
  3. 🟠 4 种 lifecycle hook:实现 processSessionLifecycle(source),source ∈ {startup, resume, clear, compact}。每种 source 调用一组 hook。验证:clear 时清除 cost tracker,resume 时不清。
  4. 🔴 SessionResetPolicy:实现 4 种模式(daily / idle / both / none)。idle_minutes 用 last_message_at 比较;daily 看本地时间是否过了 at_hour。reset 时发通知(exclude api_server / webhook)。

§11 · 面试题:10 道带答案的高频考点

Section titled “§11 · 面试题:10 道带答案的高频考点”
Q1 · 概念:为什么 Codex 用 JSONL append-only 而非单个 JSON 文件存 session?

JSONL 比整文件 JSON 在 agent 场景下三个具体优势:

1. 崩溃恢复

Agent 进程可能:被 SIGKILL、断电、OOM、被 IDE 杀掉。如果是整文件 JSON:进程崩在写到一半的时候,整个文件 corrupt 不能 parse。下次启动 resume 失败,整个 session 丢。

JSONL append-only:每行独立 parse。崩在第 N 行写到一半,丢的只是第 N 行,前面 N-1 行完整。parse_jsonl_skip_bad_lines() 就能恢复绝大部分历史。

Codex 实测:每周都有几个 corrupt 文件(用户 force quit),JSONL 让 99.9% 数据可恢复。

2. 写性能

整文件 JSON:每个 turn 后要序列化整个 session(可能几 MB)再 atomic write。turn 多了之后每次都是 IO 高峰。

JSONL:只 append 一行(几 KB),write syscall 一次。Codex 一个 session 几百 turn 时,写盘开销 < 5ms / turn。

3. 流式消费

Codex 的 TUI 想实时显示「agent 在干什么」。JSONL 可以 tail -f 流式读,每个新行就是一个事件。整文件 JSON 没法这样消费——读到文件中间 parse 失败。

JSONL 的代价

  1. 没有”修正历史”能力:append-only,写错了改不了。Codex 的解法:写错了再 append 一个「修正」事件,consumer 自己合并。
  2. 文件大小膨胀:长 session 文件大,cat 起来累。Codex 加 archived_sessions/ 子目录归档老文件。
  3. schema 演化复杂:每行的 schema 可能跨版本。Codex 用 discriminator: "type" + per-type 反序列化让旧版本 row 还能 parse 出来。

对比四家

  • Codex: JSONL + SessionMeta first line。最工程化。
  • Claude Code: multi-file(rollout / cost / attribution 各自落盘)。本质也是 append-only 思想,但分多个文件。
  • OpenClaw: session-id 校验,存哪交给上层。
  • Hermes: gateway/session JSON 持久化。整文件,因为 session 量小(每个 chat_id 一个)+ reset 频繁(每天)。

工程教训append-only log 是 agent 数据持久化的默认选择。和数据库 WAL、Kafka log、Git object store 同源思想。

源码codex/codex-rs/rollout/src/recorder.rs:80-105(RolloutRecorder)+ metadata.rs:39-65(SessionMeta 解析)。

追问:「为什么 Hermes 不用 JSONL?」Hermes 是聊天 agent,每个 chat session 短(几十轮),reset 频繁(每天)。整文件 JSON 几十 KB,整体序列化够快。+ 多平台场景,每个平台一个 chat_id,文件数太多 JSONL 不便于管理。两边场景驱动设计不同。

Q2 · 架构:Claude Code 4 种 lifecycle source(startup / resume / clear / compact)为什么不合并成 2 种或 5 种?

4 种数量恰到好处。每种对应根本不同的语义。

startup · 全新对话

  • 用户 claude 第一次启动,没有任何历史。
  • Hook 应该:加载 CLAUDE.md、设置工作目录、初始化 cost tracker / git state、按 plugin 配置注入 system prompt。
  • 不应该:从 archive 拉历史(没有历史)、恢复 worktree session(用户没要 worktree)。

resume · 恢复历史 session

  • 用户 claude --resume 选了一个 session。
  • Hook 应该:恢复 cost state、attribution snapshot、file history、todos、model override、worktree state。
  • 不应该:重置 cost tracker(resume 是要继续,不是从 0 开始)、重新加载 CLAUDE.md(已经在 history 里)。

clear · 用户主动 /clear

  • 用户在对话中 /clear 想重置上下文但保留 session 元数据。
  • Hook 应该:清 message history、保留 cost tracker(计费不重置)、保留 model override(用户喜好不变)、可能保留 todos。
  • 不应该:清 session 文件(用户后面可能想 resume)、清 plugin state(plugin 有自己的生命周期)。

compact · 上下文超阈值触发压缩

  • 系统判断 context tokens > limit,触发 compact subagent。
  • Hook 应该:snapshot 关键信息(避免压缩后丢失)、暂停 cost tracker 写入(compact 自己的 LLM 调用计费要分离)、更新 systemPrompt(compact 结果是新的 baseline)。
  • 不应该:clear message history(compact 是「精简」不是「丢弃」)、reset model(用户没改)。

为什么不合并?

合并为 2 种(new / restore):

  • 把 clear 算进 new:但 clear 不应该重新加载 CLAUDE.md(已加载),plugin state 应保留,cost tracker 不清。new 的 hook 不知道这些细节。
  • 把 compact 算进 restore:但 compact 时 session 还在活着,hook 想做的不是 restore 状态,而是 snapshot + 重启计费。

合并为 5+ 种(add: “fork” / “convert”):

  • fork 是新 session ID,但 inherits 部分历史。本质就是 startup 加一段 initial messages。复用 startup 路径加 initial_messages 参数足够,不需要新 source。
  • convert(agent → agent)也类似,inherits messages 但 reset model。

Claude Code 实测发现 4 种是「最小够用」的颗粒。每种都有清晰的「应该做什么 / 不应该做什么」。

实现细节

type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact';
async function processSessionStartHooks(source: SessionStartSource) {
for (const hook of hooks) {
if (hook.appliesTo.includes(source)) {
await hook.execute({ source, ... });
}
}
}

Hook 可以声明 appliesTo: ['startup', 'resume'](不在 clear / compact 时跑)。颗粒度让 hook 写作更精确。

工程教训lifecycle 事件的颗粒度,必须反映”hook 应该做什么不同事情”。如果两种事件下 hook 干的事一样,应该合并;不一样,必须区分。

源码claude-code/src/utils/sessionStart.ts:34-66processSessionStartHooks + 4 种 source 类型)。

追问:「Codex 没有 clear / compact source 吗?」Codex 的 lifecycle 是 RolloutRecorderParams 的 Create / Resume 两种。compact 在 Codex 是 sub-agent(chapter 10),不是 lifecycle event。clear 不存在——Codex 不鼓励 /clear,鼓励开新 thread(cheap 操作)。两种产品定位不同。

Q3 · 概念:OpenClaw 只做 UUID 正则校验把 session 存储交给上层。这是「不完整」还是「正确的边界」?

正确的边界。OpenClaw 是 agent 平台 (framework),不是 agent 产品 (application)。两者关心 session 的事完全不同:

产品视角(Codex / Claude Code / Hermes)

用户开 agent 是为了完成具体任务。产品要:

  1. 让用户能列出”过去 7 天的 session”
  2. 让用户 resume 任意 session
  3. 自动 archive 旧 session 防止文件过多
  4. 跨设备同步 session(高级产品功能)

每一条都需要 session 持久化层(JSONL / multi-file / SQLite)。

平台视角(OpenClaw)

OpenClaw 是给开发者写 agent 用的框架。开发者:

  • 写 Slack bot 的:要把 session 存到 Slack 平台的 thread,不要写本地
  • 写 IDE 插件的:要把 session 存到 IDE workspace state
  • 写 SaaS 的:要把 session 存到 PostgreSQL / Redis
  • 写 CLI 工具的:要本地 JSONL(学 Codex 模式)

OpenClaw 的取舍

如果 OpenClaw 也提供「JSONL session 持久化」,会出现两个问题:

  1. 架构绑定:用 OpenClaw + Slack bot 时,session 既存本地 JSONL 又存 Slack thread,两份数据可能不一致。
  2. 扩展困难:每加一种存储后端(PostgreSQL / Redis / S3 / cloud KV),都要在 OpenClaw 内核加 if-else。一个 platform 框架最不应该的就是 hardcode 存储。

所以 OpenClaw 选择:

  • 提供 session_id 校验(确保 ID 格式合规)
  • 提供 session_key 工具({agentId}:{sessionId} 拼接)
  • 提供 transcript-events 序列化(事件转 JSON)
  • 存哪里 / 什么时候 reset / 怎么 resume 全交给 plugin / 上层调用方

类比

数据库的 query layer 不该决定「数据存在哪」。SQLite / PostgreSQL / MySQL 的存储后端可以变,但 query layer 一致。OpenClaw 让 session 存储成为可插拔后端。

代价

OpenClaw 用户要自己处理 session 存储。这意味着:

  1. out-of-box 不够:要 demo 一个完整 agent,需要先选个 session backend。
  2. 生态分裂:不同插件可能存到不同地方,跨插件查询不便。
  3. 新手门槛:「session 怎么存?」是新人第一问,OpenClaw 答「你决定」。

OpenClaw 用文档 + 几个 plugin 示例(一个 file-based、一个 in-memory)减轻这些问题。

对比标准

判断「这个抽象是不是合理」用一个问题:业务场景下,是不是足够多样

  • session 存储后端:业务场景下多样(本地 / Slack / DB / S3)。OpenClaw 不绑定是对的。
  • session_id 格式:业务场景下不该多样(UUID 是工业标准)。OpenClaw 强制 UUID 是对的。
  • session_key 命名空间:业务场景下基本固定(agentId + sessionId)。OpenClaw 给 util 函数是对的。

每个抽象决策都用这个标准评估,不容易错。

源码openclaw/src/sessions/session-id.ts:1-6(极简 UUID 正则)+ session-key-utils.ts

追问:「但 Codex 也是『可扩展』的,怎么 Codex 还是绑定 JSONL?」Codex 不是 framework,是 product。Codex 团队决定 JSONL 是好选择,强制所有 Codex 用户用这个。OpenClaw 给的不是「Codex 的灵活版」,是「让开发者自己做 Codex 的工具」。定位不同。

Q4 · 概念:Hermes 的 SessionResetPolicy 有 4 种模式(daily / idle / both / none)。为什么是 4 种而不是 1 种?

每种对应一个真实用户群:

daily(每天 4 点 reset)· 个人助理

用户:早上让 agent 帮做事,晚上接着聊。

  • 优点:每天一个清爽 session,不积累冗余上下文
  • 缺点:如果用户深夜还在用,可能 4 点突然失忆

适合:用户作息规律的私人助理(Notion AI assistant、Telegram bot)

idle(24 小时无活动 reset)· 项目协作

用户:跟 agent 在 Slack 工作群讨论项目,可能几天才回一次。

  • 优点:以”有活动”为粒度。如果用户连续 7 天在讨论同一个项目,context 保持
  • 缺点:超过 idle 阈值后强制 reset,可能让用户体验不佳

适合:项目协作场景(Slack agent、Linear assistant)

both(任一触发就 reset)· 默认推荐

实际 Hermes 默认是 both:每天 4 点 OR idle 24 小时,任一先到的触发 reset。

  • 优点:既有”每天清盘”的稳定性,又有”工作日连续”的连贯性
  • 缺点:策略叠加,规则复杂

适合:多数场景,所以是默认。

none(永不 auto-reset)· 长期记忆 agent

用户:跟 agent 维护一个长期项目(小说创作、知识库整理)。

  • 优点:context 永远保留,agent 还「记得」用户在干什么
  • 缺点:context 会无限增长,必须依赖 compact 不然爆。Hermes 默认 compact threshold 触发后会自动压缩,不影响这个模式。

适合:长期项目 agent、creative writing assistant

配置层次

@dataclass
class SessionResetPolicy:
mode: str = "both"
at_hour: int = 4
idle_minutes: int = 1440
notify: bool = True
notify_exclude_platforms: tuple = ("api_server", "webhook")

注意 notify_exclude_platforms:reset 时发通知给用户(“agent 已重置上下文”)。但对 api_server / webhook 这种程序调用方,不发通知(程序不需要收到这个消息)。

为什么不让用户自己写 reset 逻辑?

如果只给 hook:

def custom_reset_logic(session):
if some_condition:
reset(session)

用户要自己实现 daily / idle 逻辑,每次都重新发明轮子。Hermes 直接给 4 种 enum 模式 + 配置,多数用户不用写代码。

per-platform 覆盖

reset_by_platform = {
Platform.SLACK: SessionResetPolicy(mode="idle", idle_minutes=240), # 4 小时
Platform.TELEGRAM: SessionResetPolicy(mode="both"),
Platform.LOCAL: SessionResetPolicy(mode="none"), # CLI 永不 reset
}

工作 Slack 群短 idle、私人 Telegram 长 idle、本地 CLI 永久。一个 agent 服务多种场景,per-platform 配置很必要。

工程教训reset 不是”全局策略”,是”per-platform / per-context 策略”。给固定 enum + per-context override 比让用户写代码合理。

源码hermes-agent/gateway/config.py:100-145SessionResetPolicy 定义)。

追问:「reset 跟 compact 怎么区分?」reset 是”对话归零”(清 message history,保留 plugin state);compact 是”压缩”(保留 message history 摘要,原始消息丢)。reset 触发是策略+时间;compact 触发是 token 数。两个独立机制并存。

Q5 · 概念:Codex 区分 ThreadId 和 SessionId。看起来重复,为什么不合并?

两个 ID 表达完全不同的概念:

ThreadId · 逻辑对话单位

  • 一个 thread 可以:fork(基于历史开新分支)、resume(继续)、archive(归档)
  • thread 跨时间存在:今天开始的 thread,明天 resume 继续,下周 archive
  • thread 有人类语义:用户说「我那个关于 refactoring 的对话」

SessionId · 一次具体运行实例

  • 一个 session 是:进程启动 → 用户交互 → 进程退出 的一次运行
  • session 短期存在:跟进程同生命周期
  • session 没有用户语义:用户不关心「session 12345」

两者关系

ThreadId = "thread-refactor-foo"
├─ Session 1 (Monday 10am-11am)
├─ Session 2 (Monday 3pm-4pm, resumed from Session 1)
├─ Session 3 (Tuesday 9am-10am, resumed from Session 2)
└─ Session 4 (Wednesday, archived)

一个 thread 可能跨多个 session(每次 resume 是新 session)。

为什么不能合并?

合并为单个 ID(比如都叫 thread_id):

  • 用户主动 resume 同一个 thread 两次会不会撞 ID?需要新 ID。
  • 但 thread 又是同一个逻辑对话,从用户视角不应该改名。

合并为单个 ID(都叫 session_id):

  • 那 fork 出来的新对话 ID 是什么?跟原来 fork 自的 session 什么关系?
  • 长期 archive 时需要稳定 ID,session 频繁生成新 ID 不利于跨时间引用。

两种合并都不自然。Codex 拆开是正确的。

实现细节

struct Session {
pub(crate) conversation_id: ThreadId, // 逻辑 thread ID
pub(crate) session_id: SessionId, // 本次运行 ID
// ...
}

conversation_id 是稳定的;session_id 每次启动新生成。

resume 时:

fn resume_thread(thread_id: ThreadId) -> Session {
let history = load_jsonl_by_thread(thread_id);
let session_id = SessionId::new(); // 新 session ID
Session {
conversation_id: thread_id, // 同一个 thread
session_id,
// ...
}
}

对比四家

  • Codex:ThreadId + SessionId,两个明确概念
  • Claude Code:sessionId 一个概念,但 worktree 有独立的 worktreeSessionId
  • OpenClaw:sessionId 一个,但 agent scope 引入 {agentId}:{sessionId} 命名空间
  • Hermes:session_id 一个,但 platform + chat_id 组合作为 stable identifier

每家本质都遇到「短期运行 vs 长期对话」的区分,只是命名不同。

工程教训用户视角的 ID(持久)和系统视角的 ID(运行)应该是两个东西。混淆会导致:用户找不到自己的对话、archive / migration / metric 都做不对。

源码codex/codex-rs/protocol/src/protocol.rs ThreadId/SessionId 定义 + core/src/session/session.rs:11-37(Session struct)。

追问:「fork 怎么处理 ID?」fork 是基于历史 thread 创建新 thread。Codex 的 RolloutRecorderParams::Create.forked_from_id: Option<ThreadId> 记录”从哪个 thread fork”。新 thread 有自己的 ThreadId(独立演化),但记得自己的 forked-from origin(便于追溯)。

Q6 · 实战:你要给自己的 agent 加 session 持久化。最小可用 → 生产就绪要走多远?

五个阶段,每个 1-2 周:

阶段 1 · 单文件 JSON(Day 1-2)

def save_session(session_id: str, messages: list, meta: dict):
path = f"~/.youragent/sessions/{session_id}.json"
with open(path, 'w') as f:
json.dump({"meta": meta, "messages": messages}, f)
def load_session(session_id: str):
path = f"~/.youragent/sessions/{session_id}.json"
return json.load(open(path))

第一天就能跑通,能 resume。问题:每个 turn 全量写盘,慢;崩了丢全部;不能流式 tail。

阶段 2 · 切到 JSONL append-only(Week 1)

def append_event(session_id: str, event: dict):
path = f"~/.youragent/sessions/{session_id}.jsonl"
with open(path, 'a') as f:
f.write(json.dumps(event) + "\n")
def load_session(session_id: str):
path = f"~/.youragent/sessions/{session_id}.jsonl"
return [json.loads(line) for line in open(path) if line.strip()]

参考 Codex。第一行写 SessionMeta,后续 append 每个 message / event。

阶段 3 · 加 SessionMeta + 区分 ThreadId/SessionId(Week 2)

@dataclass
class SessionMeta:
thread_id: str # 稳定,跨 session
session_id: str # 本次运行
cwd: str
model: str
git_sha: str | None
cli_version: str
created_at: str
forked_from: str | None # ThreadId
def start_session(thread_id: str | None = None):
if thread_id is None:
thread_id = uuid4()
session_id = uuid4() # 总是新生成
meta = SessionMeta(thread_id, session_id, ...)
rollout_path = f"~/.youragent/sessions/rollout-{ts}-{session_id}.jsonl"
append_event(rollout_path, asdict(meta))
return Session(meta, rollout_path)

参考 Codex。resume 走 thread_id,启动走 session_id。

阶段 4 · SQLite 索引(Week 3-4)

当 session 数量 > 100,列目录 + 解析每个 JSONL 第一行太慢。建索引:

def init_db():
conn = sqlite3.connect("~/.youragent/state.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS threads (
thread_id TEXT PRIMARY KEY,
cwd TEXT,
model TEXT,
created_at TEXT,
last_message_at TEXT,
archived BOOLEAN DEFAULT FALSE
)
""")
def on_session_start(meta: SessionMeta):
conn.execute(
"INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT REPLACE",
(meta.thread_id, meta.cwd, meta.model, meta.created_at, meta.created_at, False)
)
def list_threads_for_cwd(cwd: str):
return conn.execute(
"SELECT * FROM threads WHERE cwd = ? AND NOT archived ORDER BY last_message_at DESC LIMIT 50",
(cwd,)
).fetchall()

参考 Codex state.db。启动时 backfill:扫所有 JSONL 文件,找新增的写入 DB。

阶段 5 · 4 种 lifecycle hook(Month 2+)

class SessionLifecycle:
def on_startup(self, session): pass # 新 session
def on_resume(self, session): pass # resume 历史
def on_clear(self, session): pass # 用户 /clear
def on_compact(self, session): pass # 上下文压缩
def trigger_lifecycle(source: str, session):
for hook in registered_hooks:
getattr(hook, f"on_{source}")(session)

参考 Claude Code 4 source。每种触发对应的 hook。让 plugin 能在生命周期事件挂自己的逻辑。

阶段 6 · Reset 策略(Month 3+,多平台 agent 才需要)

@dataclass
class ResetPolicy:
mode: Literal["daily", "idle", "both", "none"] = "both"
at_hour: int = 4
idle_minutes: int = 1440
def should_reset(session: Session, policy: ResetPolicy) -> bool:
if policy.mode == "none":
return False
daily = is_past_at_hour(session.last_reset, policy.at_hour) if policy.mode in ("daily", "both") else False
idle = minutes_since(session.last_message_at) > policy.idle_minutes if policy.mode in ("idle", "both") else False
return daily or idle

参考 Hermes。如果你的 agent 服务多平台,per-platform 覆盖。

关键经验

  1. 第一天 JSONL,不要单文件 JSON:避免后期迁移成本
  2. 第二周 ThreadId/SessionId 分开:用户视角和系统视角必须分
  3. SQLite 索引等性能问题真出现再加:不到 100 session 不用
  4. lifecycle hook 是平台化路径:单产品可以不要
  5. Reset 策略只在 chat agent 上必要:编程助手 / IDE 不需要

源码组合:Codex rollout/src/recorder.rs + metadata.rs + state_db.rs(基础三件套)→ Claude Code sessionStart.ts + sessionRestore.ts(生命周期)→ Hermes gateway/session.py + config.py(多平台 + reset)。从 1 到生产化的源码地图。

追问:「跨设备同步要怎么做?」存储后端切到 cloud(S3 / DynamoDB / Firebase);rollout JSONL 改成 stream upload;用户登录后 sync 本地 cache。架构变化大,建议从 cloud-first 起步,不要先 local 再迁移。

Q7 · 架构:Claude Code 注释里强调”do not add ANY warmup logic”。为什么这条铁律重要?

启动路径是 agent UX 的命脉。延迟在这里失控会传到所有用户。

真实故事

Claude Code 2.0 之前,曾经有过这些”warmup”逻辑:

  1. startup 时扫 ~/.claude 目录:找过去 session,准备 quick-resume 列表。3 秒。
  2. startup 时加载所有 plugins:避免后续 lazy load 延迟。5 秒。
  3. startup 时 git status:预填 git context。1-3 秒(取决于 repo 大小)。
  4. startup 时 fetch latest version:检查更新。2 秒。

加起来 startup 11 秒。用户输入 claude 后盯着光标 11 秒。

为什么会这样发生?

每个 warmup 单独来看都合理:

  • “扫历史 session 加速 resume” — 帮用户更快进入工作
  • “加载 plugins” — 避免后续延迟
  • “git status” — context 准备
  • “fetch version” — 安全 / 修 bug

每个 PR 增加 1-3 秒。一年下来 startup 从 2 秒变 11 秒。没有任何一个 reviewer 说不,因为每个看起来都”小且必要”。

铁律由来

Anthropic 内部发现这个问题后,加了三道防线:

  1. 代码注释直接禁止:source 里写明”do not add ANY warmup”,新 PR 看到这条要解释。
  2. startup time SLA:CI 跑 claude --version 必须 < 200ms;超了 fail。
  3. defer-by-default:所有非必需的初始化 lazy load(plugins / sessions / git)。

Why 200ms ?

人类感知”立即响应”的阈值。CLI 工具 > 200ms 用户会感到”卡”。Claude Code 想做”飞快”的 CLI。

应该 lazy load 什么?

  • 历史 session 列表:用户 --resume 时才扫,不是 startup
  • plugins:要用时才加载(每个 plugin 注册自己的 trigger)
  • git context:第一次需要 git info 时再 status
  • version check:后台异步,启动不阻塞

什么必须 startup 做?

  • 解析 CLI args
  • 验证 API key(不验后续每次调用都会失败)
  • 设置 logger
  • 注册 signal handler

加起来 50ms。

Codex 也学了

Codex CLI 启动 < 100ms(Rust 帮了大忙)。codex 输入到 prompt 出现,几乎 instant。session 列表是 lazy(用 /threads 命令才查)。state.db 启动时不加载,需要时再 open。

Hermes 反例

Hermes 启动慢(5-10 秒),因为要:

  • 连 6+ messaging platform(每个都要 OAuth handshake)
  • 加载所有 plugin
  • 初始化 cron scheduler

启动慢是 server 类应用的常态。但 Hermes 用户输入是 chat message,不是 CLI command。启动慢只影响一次冷启动,不影响每次交互。和 CLI agent 完全不同要求。

工程教训CLI agent 启动 < 200ms 是产品力。每加一个 warmup,先问”能不能 lazy”。能 lazy 一定 lazy。

源码claude-code/src/utils/sessionStart.ts:34(注释里的禁令)。

追问:「但用户希望 --resume 快,怎么做?」--resume 时才查 SQLite 索引(state.db)。索引在 startup 后台 lazy load(不阻塞用户输入)。如果用户立刻 --resume,可能 100ms 等索引加载,但只在这种特定 flow 上。

Q8 · 实战:用户报告”resume 之后 agent 表现得像换了一个人”。系统化排查。

resume 失忆症状本质是「状态恢复不完整」。分 4 层排查:

第一层 · 消息历史(最常见)

检查:

session = load_session(thread_id)
print(f"加载了 {len(session.messages)} 条消息")
print(f"最后一条: {session.messages[-1]}")

如果 messages 数量不对(少了 / 截断了),是 JSONL 解析错或文件损坏。Claude Code 见过的 bug:

  • archived_sessions/ 没正确读,只读了 active 目录
  • 跨版本 schema 不兼容,新版本 parser 跳过老 row
  • 文件被外部修改(用户手动编辑了 JSONL 想 debug)

第二层 · 系统元数据

哪怕 messages 全对,agent 行为可能因为:

# 检查 resume 后这些是不是恢复了
print(f"cwd: {session.cwd}") # 工作目录
print(f"model: {session.model}") # 模型选择
print(f"approval_mode: {session.approval_mode}") # 审批策略
print(f"git_sha: {session.git_sha}") # git 状态

典型 bug:

  • resume 时 cwd 没恢复,agent 不在原项目目录(找不到 file)
  • model override 没恢复,从 sonnet 切回 opus(行为不同)
  • approval mode 没恢复,原来 --accept-edits 现在变 interactive(卡在每个 edit)

第三层 · 子系统状态

Claude Code 7 类要恢复:

sessionRestore({
cost: ..., # 计费状态
attribution: ..., # 用户归因
file_history: ..., # 改过哪些文件
todos: ..., # 待办
model_override: ...,
worktree_state: ...,
system_prompt: ..., # 上下文额外注入
})

最容易漏的是 system_prompt sections。Claude Code 的 system_prompt 由多个 section 组成(CLAUDE.md + plugin 注入 + 工具描述 + 用户自定义)。resume 时如果只恢复了一部分,agent 不知道某些工具能用、不知道项目约定。

第四层 · 上下文窗口管理

如果消息太多被 compact 过,resume 时可能:

  • compact 后的 summary 没保留,agent 不知道历史发生过什么
  • 原始消息保留但 compact summary 也保留,导致重复(context bloat)

典型修复流程

  1. 让用户复现,记下 thread_id
  2. 看 rollout JSONL 文件,确认行数 + SessionMeta 完整
  3. 加 debug log 在 sessionRestore 每一步:「准备恢复 cost」「准备恢复 attribution」…
  4. resume 后 dump 实际 session state,对比 SessionMeta 期望状态
  5. 找出哪一类没恢复,写测试

预防

  • resume 端到端测试:每个 release 跑 fixture session(已知历史)做 resume,对比 100 个 assertion
  • schema versioning:SessionMeta 加 schema_version,旧版本数据走兼容路径
  • resume metric:埋点 resume 成功率、用户 resume 后 N 分钟内主动 /clear 的比例(侧面 indicator)

对比四家

  • Codex 的 resume 比较稳:JSONL line-by-line replay,state DB 只是索引不影响 session 内容。
  • Claude Code 的 resume 复杂:7 类状态分布在 7 个子系统。每个都要小心。
  • OpenClaw 的 resume 取决于上层实现:framework 不管。
  • Hermes 的 resume 是 chat session:状态少(只有 message history + SessionContext),出错少。

工程教训resume 不是”重放 messages”,是”重建完整 session state”。多少子系统就有多少东西要恢复。每加一个子系统,resume path 要更新。

源码claude-code/src/utils/sessionRestore.ts:1-58(7 类状态导入列表,每个都要恢复)。

追问:「resume 失败时 fallback 策略?」分级:(1) message 解析失败 → 跳过损坏行;(2) cwd 不存在 → 提示用户选新 cwd;(3) model 失效 → fallback 到默认模型;(4) 整个 session 不能恢复 → 提示用户「session 损坏,开新对话还是导出旧消息?」让用户决定。

Q9 · 工程:Hermes 把 _PII_SAFE_PLATFORMS 列出 4 个平台允许 PII redaction。这个清单怎么维护?

清单不是猜的,是从两个具体约束逆推:

约束 1 · 平台的 mention 语法

不同平台 mention 用户的方式:

  • WhatsApp:自然语言 @用户名(不需要内部 ID)
  • Signal:电话号码 / UUID(用户级别 ID)
  • Telegram:自然语言 + @username(不需要内部 ID)
  • BlueBubbles:phone number(人可读)
  • Discord<@user_id> 语法(必须用内部数字 ID)
  • Slack:<@U12345678> 语法(必须用 Slack member ID)

如果平台 mention 需要内部 ID,那 LLM 必须看到原始 user_id 才能生成正确 mention。redact 掉 = mention 失败。

所以:

  • 能 redact:WhatsApp / Signal / Telegram / BlueBubbles → 在 _PII_SAFE_PLATFORMS
  • 不能 redact:Discord / Slack → 不在清单里

约束 2 · routing 跟 LLM input 分离

Hermes 设计上 SessionSource 永远保留原始 ID(用于路由),LLM 看到的是 redact 后的版本:

session_source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="123456789",
user_id="987654321",
user_name="Alice",
)
# 路由:原始 ID
route_response(session_source.chat_id, session_source.user_id, response)
# LLM input:redact 后
prompt = build_session_context_prompt(session_source, redact_pii=True)
# prompt 里 user_id 变成 hash_user_001

LLM 不知道真实 user_id,但 Hermes 系统知道。LLM 生成的回复要发给”hash_user_001”时,Hermes 内部映射回真实 user_id 再发送。

清单更新规则

  • 新增平台支持:先研究 mention 语法。如果用 internal ID,加入「不能 redact」;如果用自然语言,加入 _PII_SAFE_PLATFORMS
  • 平台改协议:罕见。但比如 Telegram 5.0 开始要求 user_id mention,得移出 safe 列表。
  • 法律 / 合规改变:如果 GDPR / CCPA 要求所有 user 数据脱敏发给 LLM,那 redact 不再是可选,所有平台都强制(即使 mention 失败也接受)。

为什么不让所有平台都 redact?

mention 是产品的”agent 能跟你互动”的核心信号:

  • Slack 群里 agent 回复时 @你:你知道是给你的
  • Discord channel 里 agent 提到你:你能看到

如果 LLM 看不到真实 ID 没法生成 mention,agent 的回复变成”普通消息”,UX 大幅退步。

所以 Hermes 的选择是:

  • 默认 redact_pii=False(不脱敏,保证功能)
  • 用户配置 redact_pii=True 时,只对 PII_SAFE_PLATFORMS 生效
  • Discord / Slack 上要 redact 时,强制 fallback 不 redact + 加 audit log(让用户知道这些平台没 redact)

对比工业实践

OpenAI 的 ChatGPT 商业版:

  • 默认所有 user input 都 redact(公司怕泄密)
  • 但同一个 user 在不同 chat 用 hash_user_001 不一致,agent 不能跨 chat 记得用户
  • 产品力受损但符合合规要求

Hermes 是个人 / 私人 agent,UX 优先;ChatGPT 是企业 agent,合规优先。设计取向不同。

工程教训安全决策一定要追到产品 / 平台 / 法律的具体需求。不能”为了安全而安全”,会损失产品力。文档里把 trade-off 讲清楚(“Discord 不能 redact,因为 mention 要 raw ID”)让维护者理解 why。

源码hermes-agent/gateway/session.py:176-209_PII_SAFE_PLATFORMS 定义 + 注释里讲清楚 Discord 为什么 excluded)。

追问:「如果用户不在乎 mention 失败,要全 redact 怎么办?」配置项 force_redact_all_platforms=True。Hermes 不直接给(怕 UX 退步),但 enterprise / regulated 部署可以打开。

Q10 · 开放:设计一个「通用 session 框架」,综合四家长处。给出最小完整 API + 实现 outline。

分层设计,按需启用:

Layer 1 · 核心 ID(必需)

type ThreadId = string; // 稳定,跨 session
type SessionId = string; // 本次运行
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function newThread(): ThreadId { return crypto.randomUUID(); }
function newSession(): SessionId { return crypto.randomUUID(); }
function isValidSessionId(id: string): boolean { return SESSION_ID_RE.test(id); }

参考 OpenClaw UUID 正则 + Codex thread/session 分离。

Layer 2 · SessionMeta(必需)

interface SessionMeta {
thread_id: ThreadId;
session_id: SessionId;
forked_from?: ThreadId;
cwd: string;
model: string;
git_sha?: string;
cli_version: string;
created_at: string;
agent_role?: string;
agent_nickname?: string;
}

参考 Codex SessionMeta。落 JSONL 第一行。

Layer 3 · JSONL Rollout(推荐)

interface RolloutRecorder {
appendEvent(event: RolloutItem): Promise<void>;
flush(): Promise<void>;
close(): Promise<void>;
}
type RolloutItem =
| { type: 'session_meta'; meta: SessionMeta }
| { type: 'response_item'; item: ResponseItem }
| { type: 'turn_context'; ctx: TurnContext }
| { type: 'compacted'; summary: string }
| { type: 'event_msg'; event: EventMsg };
class FileSystemRollout implements RolloutRecorder {
// ~/.youragent/sessions/rollout-{ts}-{session_id}.jsonl
// append-only, mpsc async write
}

参考 Codex RolloutRecorder + 5 种 RolloutItem。

Layer 4 · SQLite 索引(生产推荐)

interface SessionIndex {
saveThread(meta: SessionMeta): Promise<void>;
listThreads(filter: ThreadFilter): Promise<ThreadSummary[]>;
findThread(id: ThreadId): Promise<ThreadSummary | null>;
archiveThread(id: ThreadId): Promise<void>;
}
class SqliteSessionIndex implements SessionIndex {
// ~/.youragent/state.db
// schema: threads(thread_id, cwd, model, created_at, last_message_at, archived)
// backfill from rollout files on startup (lazy)
}

参考 Codex state.db。listThreads 不扫文件用 SQL。

Layer 5 · 生命周期 Hook(推荐)

type SessionSource = 'startup' | 'resume' | 'clear' | 'compact';
interface SessionLifecycleHook {
appliesTo: SessionSource[];
execute(source: SessionSource, session: Session): Promise<void>;
}
class SessionLifecycle {
private hooks: SessionLifecycleHook[] = [];
register(hook: SessionLifecycleHook) {
this.hooks.push(hook);
}
async trigger(source: SessionSource, session: Session) {
for (const hook of this.hooks) {
if (hook.appliesTo.includes(source)) {
await hook.execute(source, session);
}
}
}
}

参考 Claude Code 4 种 source + plugin/user hook trust 边界。

Layer 6 · SessionRestore(推荐)

interface RestoreableSubsystem<T> {
name: string;
snapshot(session: Session): T;
restore(state: T, session: Session): Promise<void>;
}
class SessionRestore {
private subsystems: RestoreableSubsystem<any>[] = [];
register<T>(sub: RestoreableSubsystem<T>) {
this.subsystems.push(sub);
}
async restoreAll(state: Record<string, any>, session: Session) {
for (const sub of this.subsystems) {
const subState = state[sub.name];
if (subState !== undefined) {
await sub.restore(subState, session);
}
}
}
}

参考 Claude Code 多子系统恢复。每个子系统自己实现 snapshot/restore。

Layer 7 · 多平台 SessionSource(可选 · chat agent 需要)

interface SessionSource {
platform: 'cli' | 'slack' | 'telegram' | 'discord' | ...;
chat_id: string;
chat_type: 'dm' | 'group' | 'channel';
user_id?: string;
user_name?: string;
thread_id?: string;
}
interface BuildContextOptions {
redact_pii?: boolean;
}
function buildSessionContextPrompt(
source: SessionSource,
opts: BuildContextOptions = {}
): string {
// 注入 system prompt:where messages come from, what platforms connected
// redact_pii: only for safe platforms
}

参考 Hermes SessionSource + PII redaction。

Layer 8 · ResetPolicy(可选 · chat agent 需要)

interface ResetPolicy {
mode: 'daily' | 'idle' | 'both' | 'none';
at_hour?: number;
idle_minutes?: number;
notify?: boolean;
notify_exclude_platforms?: string[];
}
class ResetEngine {
shouldReset(session: Session, policy: ResetPolicy): boolean {
if (policy.mode === 'none') return false;
const daily = policy.mode === 'daily' || policy.mode === 'both' ?
this.isPastResetHour(session, policy.at_hour!) : false;
const idle = policy.mode === 'idle' || policy.mode === 'both' ?
this.isIdleTimeout(session, policy.idle_minutes!) : false;
return daily || idle;
}
}

参考 Hermes 4 种 reset 模式 + per-platform override。

总 API

import { SessionManager } from '@your-org/session';
const sm = new SessionManager({
storage: new FileSystemRollout('~/.myagent'),
index: new SqliteSessionIndex('~/.myagent/state.db'),
resetPolicy: { mode: 'both', at_hour: 4, idle_minutes: 1440 },
});
// Start new
const session = await sm.startSession({ cwd: '/foo', model: 'opus' });
// Resume by thread_id
const resumed = await sm.resume(threadId);
// Lifecycle hook
sm.lifecycle.register({
appliesTo: ['startup', 'resume'],
execute: async (source, session) => {
if (source === 'startup') {
// 加载 CLAUDE.md
} else {
// 恢复 cost tracker
}
},
});
// Multi-platform routing (optional)
const platformSession = await sm.fromSource({
platform: 'telegram',
chat_id: '123',
user_id: '456',
});

vs 四家

  • Codex:Layer 1-4(核心 ID + Meta + JSONL + SQLite)
  • Claude Code:Layer 1-6(+ lifecycle + restore)
  • OpenClaw:Layer 1(只校验 ID)
  • Hermes:Layer 1, 2, 7, 8(+ 多平台 + reset)

实现工作量

  • Layer 1-3:1-2 周
  • Layer 4-6:3-4 周
  • Layer 7-8:2-3 周(如果是 chat agent)

总共 6-9 周到生产就绪。

关键决策

  1. JSONL 是默认:不要单文件 JSON
  2. Thread/Session 分开:UID 不要混用
  3. SQLite 索引等数据量大才上:< 100 session 不必要
  4. Lifecycle hook 是平台化路径:单产品可以不要
  5. 多平台 / Reset 只在 chat agent 上必要:编程 agent 不需要

追问:「跨设备同步怎么加?」Layer 9:CloudSync 层。把 RolloutRecorder 实现切到 cloud storage(S3 / GCS / Azure Blob),index 切到 cloud DB(DynamoDB / Firestore)。用户登录后 sync 本地 cache。架构改动大,建议从一开始就设计 cloud-first。

源码组合:Codex rollout/ + core/session/ (基础三层) → Claude Code utils/sessionStart.ts + utils/sessionRestore.ts (生命周期) → OpenClaw sessions/session-id.ts (极简校验) → Hermes gateway/session.py + gateway/config.py (多平台 + reset)。四家代码拼一起 = session 框架 v0.1。