PicoClaw Session 系统架构解析:从路由作用域到 JSONL 持久化的完整链路
2026/9/19 6:38:57 网站建设 项目流程

PicoClaw Session 系统架构解析:从路由作用域到 JSONL 持久化的完整链路

【免费下载链接】picoclawTiny, Fast, and Deployable anywhere — automate the mundane, unleash your creativity项目地址: https://gitcode.com/gh_mirrors/pi/picoclaw

本文深入解析 PicoClaw 运行时的 Session(会话)系统:它如何把入站消息映射到稳定会话作用域、如何以 JSONL 格式跨 turn 与跨进程重启持久化上下文,又如何在运行时全面采用不透明 canonical key 的同时兼容旧版agent:...session key。读完本文,你将掌握pkg/sessionpkg/memorypkg/agent三层的核心链路,理解会话维度的分配规则、崩溃恢复语义与迁移回退机制,并能在实际部署中正确配置session.dimensions与 dispatch 规则。本文内容基于当前仓库 docs/architecture/session-system.zh.md,不讨论 web/backend/middleware 中 launcher 登录 Cookie 或 dashboard 鉴权 session。

Session 系统的四大职责

Session 系统在 PicoClaw 运行时中承担四件核心事务:

  1. 决定消息归属:判断哪些消息应当共享同一段上下文(同一段历史记忆);
  2. 持久化上下文:让这段上下文能够跨 turn、跨进程重启持续存在;
  3. 抽象存储接口:向 agent loop 暴露一个足够小的SessionStore接口,屏蔽底层后端差异;
  4. 兼容旧格式:在存储层与路由层迁移期间,继续兼容旧版 session key(agent:...形式)。

主要组件总览

整个会话链路由五个层次构成,从抽象的接口定义到具体的磁盘文件:

层次文件作用
Session 抽象pkg/session/session_store.go定义 agent loop 依赖的SessionStore接口。
旧后端pkg/session/manager.go每个 session 一个 JSON 文件的旧实现,仍作为回退方案保留。
Session 适配层pkg/session/jsonl_backend.gopkg/memory.Store适配成SessionStore,并支持 alias 与 scope metadata。
持久化存储pkg/memory/jsonl.goAppend-only JSONL 存储与.meta.json元数据侧文件。
Scope / Key 构建pkg/session/scope.go、pkg/session/key.go、pkg/session/allocator.go从路由结果生成结构化 scope、不透明 canonical key 和 legacy alias。
运行时集成pkg/agent/instance.go、pkg/agent/agent_utils.go、pkg/agent/agent_message.go初始化存储、分配 session scope,并在 turn 执行前落 metadata。

SessionStore 接口:agent loop 的唯一依赖

SessionStore接口(pkg/session/session_store.go#L13-L33)只暴露与 agent loop 相关的持久化操作:追加消息(AddMessage/AddFullMessage)、读取历史(GetHistory)、读写摘要(GetSummary/SetSummary)、替换历史(SetHistory)、逻辑截断(TruncateHistory)、保存(Save)、枚举会话(ListSessions)与释放资源(Close)。

值得注意的设计细节是:写方法(Add*Set*Truncate*)都是 fire-and-forget 的,不返回错误,实现内部自行记录失败日志。这是为了匹配旧SessionManager的原有契约,让 agent loop 的调用方代码无需关心底层存储失败——任何满足该接口的后端都可以被无缝替换。

Session 数据模型:结构化作用域 SessionScope

结构化的会话身份由session.SessionScope表示(pkg/session/scope.go#L7-L14):

字段含义
VersionScope 模式版本,当前为ScopeVersionV1(值为1)。
AgentID处理该 turn 的路由 agent。
Channel归一化后的入站 channel 名称(为空时回退为unknown)。
Account归一化后的 bot / account 标识。
Dimensions当前启用的隔离维度顺序,例如chatsender
Values每个维度对应的具体归一化值。

Allocator 当前只识别四个维度:

  • space
  • chat
  • topic
  • sender

默认配置是按 chat 共享上下文(pkg/config 中SessionDimensions的默认值),对应配置片段为:

{ "session": { "dimensions": ["chat"] } }

也就是说,默认情况下同一个 chat 内的所有消息共享同一段上下文;如果 dispatch rule 覆盖了维度,则以 rule 为准(详见下文「路由策略如何决定维度」)。

ScopeVersionV1常量定义在 pkg/session/scope.go#L3-L4;SessionScope还提供了CloneScope深拷贝方法,供 metadata 读取时安全返回给调用方,避免上层误改内部状态。

Canonical Key 与 Legacy Alias

不透明 canonical key

运行时现在优先使用不透明 canonical key,格式为:

sk_v1_<sha256>

它由 pkg/session/key.go 计算得到:BuildSessionKey(scope)先调用CanonicalScopeSignature(scope)(pkg/session/key.go#L183-L200)把 scope 序列化为稳定的字符串,再交给BuildOpaqueSessionKey做 SHA-256 摘要并加上sk_v1_前缀(pkg/session/key.go#L25-L32)。

canonical signature 的构成如下:

v=<version>|agent=<agent_id>|channel=<channel>|account=<account>|<dim>=<value>|...

其中维度部分按Dimensions的顺序拼接,所有字段都会做小写化与 trim。这样设计的好处是:存储 key 稳定可复算,同时不再把持久化格式与某一种旧文本 key 绑定死,未来即使改变 legacy 格式,canonical key 也能保持稳定。

Legacy alias

为了兼容旧数据,allocator 还会生成 legacy alias,例如:

agent:main:direct:user123 agent:main:slack:channel:c001 agent:main:pico:direct:pico:session-123

这些 alias 很重要,因为旧 session、部分测试以及某些工具仍然会引用这种格式。构建函数包括 pkg/session/key.go#L96-L109 的BuildLegacyDirectAliases(direct 场景生成三条:最短的agent:<agent>:direct:<peer>、带 channel 的、带 channel+account 的)与 pkg/session/key.go#L111-L123 的BuildLegacyPeerAlias(群聊/频道场景生成agent:<agent>:<channel>:<peerKind>:<peerID>)。所有 alias 都会去重、转小写。

JSONL backend 会在读写前先把 alias 解析回 canonical key(ResolveSessionKey)。此外,如果调用方已经显式传入了受支持的 session key,agent loop 会保留它,不强行改成新分配的 routed key——这条逻辑在 pkg/agent/agent_utils.go#L364-L369 的resolveScopeKey中:

func resolveScopeKey(routeSessionKey, msgSessionKey string) string { if isExplicitSessionKey(msgSessionKey) { return msgSessionKey } return routeSessionKey }

其中「显式 key」的判断由 pkg/session/key.go#L44-L46 的IsExplicitSessionKey完成,涵盖两类格式:

  • 不透明 canonical key(sk_v1_前缀,IsOpaqueSessionKey
  • legacyagent:...key(IsLegacyAgentSessionKey

分配流程:从入站消息到会话读写

普通入站消息的完整链路如下:

InboundMessage -> RouteResolver.ResolveRoute(...) -> session.AllocateRouteSession(...) -> resolveScopeKey(...) -> ensureSessionMetadata(...) -> AgentLoop turn 执行 -> SessionStore 读写

具体来说:

  1. pkg/agent/agent_message.go 先用归一化后的 inbound context 解析 agent route;
  2. session.AllocateRouteSession(pkg/session/allocator.go#L32-L43)把 route 的SessionPolicy和 inbound context 组合成结构化SessionScope
  3. Allocator 会生成四类产物(Allocation结构,pkg/session/allocator.go#L14-L20):
    • SessionKey:当前路由会话的 canonical key
    • SessionAliases:该路由会话的兼容 alias
    • MainSessionKey:agent 级主会话 key(由 legacy main alias 哈希而来)
    • MainAliases:主会话对应的 legacy alias(即agent:<agent>:main
  4. runAgentLoop通过ensureSessionMetadata(pkg/agent/agent_utils.go#L399-L410)持久化 scope metadata 和 alias;
  5. 后续读写时,JSONLBackend.ResolveSessionKey会先把 alias 映射回 canonical key。

MainSessionKey与普通聊天会话是分开的,它主要服务于 agent 级、系统级的上下文场景(比如processSystemMessage)。从源码看,主会话 alias 为agent:<agent_id>:main(pkg/session/key.go#L85-L87),其 canonical 形式由BuildMainSessionKey生成。

路由策略如何决定维度

RouteResolver在解析路由时会附带生成SessionPolicy(pkg/routing/route.go#L11-L15),包含DimensionsIdentityLinks。其取值逻辑在 pkg/routing/route.go#L101-L110:

func (r *RouteResolver) sessionPolicy(rule *config.DispatchRule) SessionPolicy { dimensions := r.cfg.Session.Dimensions if rule != nil && len(rule.SessionDimensions) > 0 { dimensions = rule.SessionDimensions } return SessionPolicy{ Dimensions: normalizeSessionDimensions(dimensions), IdentityLinks: cloneIdentityLinks(r.cfg.Session.IdentityLinks), } }

也就是说:默认使用全局session.dimensions;一旦某条 dispatch rule 显式声明了session_dimensions,则以 rule 为准normalizeSessionDimensions会把维度名小写化并过滤掉space/chat/topic/sender之外的未知值(pkg/routing/route.go#L112-L124)。相关测试见 pkg/config/config_test.go(TestDefaultConfig_SessionDimensions等)。

Scope 构建规则

pkg/session/allocator.go 的buildSessionScope会从归一化后的 inbound context 生成 scope 值(pkg/session/allocator.go#L45-L113),关键规则如下:

  • space变成<space_type>:<space_id>(type 为空时回退为space
  • chat变成<chat_type>:<chat_id>(type 为空时回退为direct
  • topic变成topic:<topic_id>
  • sender会先经过session.identity_links归一化再写入

此外还有两个需要单独记住的特殊规则。

Telegram forum 隔离

Telegram forum topic 必须默认保持隔离,即使配置只写了chat维度。为此,如果消息来自 Telegram forum 且策略里没有显式包含topic,allocator 会把/<topic_id>拼到chat值后面(判断函数为shouldPreserveTelegramForumIsolation,pkg/session/allocator.go#L150-L164)。

例如:

group:-1001234567890/42 group:-1001234567890/99

这两者会得到不同的 session key——同一群组的不同 forum 子主题各自拥有独立上下文,互不串扰。

Identity links

session.identity_links可以把多个 sender 标识折叠为一个 canonical identity。核心实现是CanonicalSessionIdentityID(pkg/session/key.go#L127-L136)与resolveLinkedPeerID(pkg/session/key.go#L146-L181):它会把原始 sender ID 及其带 channel 前缀的形式、冒号后的部分都作为候选,与 identity_links 配置中的条目匹配,命中则替换为 canonical 名称。dispatch 匹配和 session 分配都会使用这套映射,因此同一个人即使跨 channel 或 account 使用不同原始 sender ID,也可以继续落到同一段上下文里

存储格式:JSONL 主文件 + metadata 侧文件

默认运行时后端是pkg/memory.JSONLStore(pkg/memory/jsonl.go#L65-L77),外面包了一层session.JSONLBackend。每个 session 使用两类文件:

{sanitized_key}.jsonl {sanitized_key}.meta.json

各自保存:

  • .jsonl:一行一个providers.Message,append-only
  • .meta.json:摘要、时间戳、行数、逻辑截断偏移、scope、aliases

sanitizeKey(pkg/memory/jsonl.go#L101-L106)会把 session key 中的:/\全部替换为_,这样 Telegram forum 的chatID/threadID、Slack 的channel/thread_ts等复合 ID 不会产生子目录,也兼容 Windows 文件名规则;同时与pkg/session的 sanitize 逻辑保持一致,保证迁移路径对齐。

SessionMeta当前包含(pkg/memory/jsonl.go#L43-L52):

字段JSON 键含义
Keykey原始 session key
Summarysummary会话摘要
Skipskip逻辑截断偏移(被跳过的行数)
Countcount已记录的消息行数
CreatedAtcreated_at创建时间
UpdatedAtupdated_at更新时间
Scopescope结构化 scope 的原始 JSON(pkg/memory保持与上层解耦)
Aliasesaliaseslegacy alias 列表

写入与崩溃语义:宁可读到旧数据,不要丢数据

JSONL store 的设计核心是「追加优先、宁可暂时读到旧数据也不要丢数据」:

  • AddMessage/AddFullMessage先追加一行 JSON,再fsync,最后更新 metadata(pkg/memory/jsonl.go#L593-L652)。注意其中的f.Sync()调用——注释明确指出,没有 Sync 的话断电后 append 可能只停留在内核 page cache,重启即丢失;
  • TruncateHistory先做逻辑截断,本质上只是推进meta.Skip(pkg/memory/jsonl.go#L711-L747)。它不会物理删除任何行,GetHistory读取时按Skip跳过前面的行;
  • Compact才会真正重写 JSONL 文件,把被跳过的旧行物理移除(pkg/memory/jsonl.go#L796-L833),并通过WriteFileAtomic(临时文件 + fsync + rename)原子替换;
  • SetHistoryCompact先写 metadata 再改写 JSONL(如 pkg/memory/jsonl.go#L778-L787 注释所说明):如果中途崩溃,meta 已是Skip=0而旧文件仍完整,GetHistory会从头读取,最多「多读到」已截断的旧消息,但不会丢数据,下次SetHistory/Compact会纠正;
  • 读取 JSONL 时如果碰到损坏行(例如崩溃产生的半截写),会记录日志并跳过该行,而不是让整个 session 读取失败(pkg/memory/jsonl.go#L512-L525);读取器还设置了 10 MB 的单行上限,以容纳read_file、web search 等大型工具结果。

一个容易被误解的语义变化:JSONLBackend.Save对应到底层的store.Compact(...)(pkg/session/jsonl_backend.go#L176-L182)。也就是说,Save在新实现里不再是「把内存脏数据刷盘」——因为每次写入都已fsync落盘——而是「在逻辑截断后回收无效行占用的磁盘空间」;若没有待回收的跳过行,则直接返回 no-op。

并发模型:固定 64 分片锁

pkg/memory.JSONLStore使用固定 64 分片 mutex,按 session key 的 FNV-32a hash 做串行化(pkg/memory/jsonl.go#L82-L86)。这样既能做到「按 session 串行」,又不会因为 session 数量增长而把 mutex map 做成无界结构——这对长时间运行的守护进程很重要。另外在 alias 提升、SetHistory等需要同时操作两个 key 的场景,lockSessionPair(pkg/memory/jsonl.go#L363-L384)会按 key 字典序加锁,避免死锁。

旧的SessionManager则是一个内存 map 加 RW mutex。这两个实现都满足同一个SessionStore接口,所以 agent loop 不需要写任何存储后端特化逻辑。

兼容与迁移:JSONL 优先,失败回退

pkg/agent/instance.go#L483-L504 的initSessionStore会优先初始化 JSONL 后端,启动过程如下:

  1. 创建memory.NewJSONLStore(dir)
  2. 执行memory.MigrateFromJSON(...)(pkg/memory/migration.go#L31),把旧.jsonsession 迁入新格式;
  3. session.NewJSONLBackend(store)包装;
  4. 如果 JSONL 初始化或迁移失败,则回退到session.NewSessionManager(dir)

这个回退是刻意设计的:做一半的迁移,比整轮继续使用旧后端更危险。注释明确说明,迁移失败意味着该目录下存储无法可靠写入,若继续使用 JSONL 会出现「部分 session 在 JSONL、部分仍在 JSON」的分裂状态,所以宁可整体回退到旧后端跑一轮。

Alias 提升(PromoteAliasHistory)

第一次为 canonical key 建 metadata 时,EnsureSessionMetadata(pkg/session/jsonl_backend.go#L66-L96)会先UpsertSessionMeta写入 scope 与 aliases,再调用PromoteAliasHistory尝试把某个非空 legacy alias 的历史提升到 canonical session(pkg/memory/jsonl.go#L237-L262)。但这件事只会在canonical session 仍然为空时发生,因此不会覆盖已经存在的 canonical 历史(sessionHasVisibleContentLocked会先检查)。

提升过程还做了两项防护:

  • 主会话 alias 不会被提升isMainSessionAlias(pkg/memory/jsonl.go#L267-L290)同时识别 legacy 形式agent:main:main(精确三段)与 opaque 形式(对agent:main:main/agent:Main:main/agent:MAIN:main的哈希),因为主会话是共享的全局回退,把它提升进各个会话会把陈旧消息附加到每个 Web UI 新会话上(注释引用了 issue #2972);
  • 写入失败可回滚promoteAliasHistoryLocked(pkg/memory/jsonl.go#L386-L445)在重写 JSONL 前会先保留原始字节,若随后写 meta 失败则用restoreRawJSONL回滚。

这保证了系统在迁移到 opaque key 的同时,仍能保留旧历史,例如:

  • 旧的 direct-message key
  • 旧的 Pico direct-session key

ResolveSessionKey(pkg/memory/jsonl.go#L295-L353)则是 alias 映射的核心:对于不含:/\的简单 key 且文件直接存在时走短路返回;否则扫描目录下所有.meta.json,若入参命中某个 meta 的Aliases列表则返回其Key(canonical key),还支持 meta.Key 精确匹配与「存在直接文件」的兜底。

其他 SessionStore 实现:ephemeralSessionStore

pkg/agent/subturn.go#L608-L701 里定义了ephemeralSessionStore。它同样实现SessionStore,但只存在于内存里,在 sub-turn 结束时销毁(SaveClose均为空实现)。这样 SubTurn 就能复用相同的 session 接口,而不会把子任务历史写进父会话的持久存储——父子会话上下文清晰隔离。

运行时消费者:不止 agent loop

Session 系统不只被 agent loop 使用:

  • web/backend/api/session.go 通过GET /api/sessions读取 JSONL metadata 和旧 JSON session,并把历史暴露给 launcher UI(注意其读取器与共享 JSONL store 保持相同的 10 MB 行大小限制);
  • pkg/agent/agent_steering.go 可以在 steering 场景下恢复 scope metadata;
  • 因为 alias 解析发生在 agent loop 之下(JSONLBackend 层),测试和工具仍然可以继续使用 legacy alias,无需感知底层 key 格式的演进。

相关文件索引

  • pkg/session/session_store.go
  • pkg/session/manager.go
  • pkg/session/jsonl_backend.go
  • pkg/session/scope.go
  • pkg/session/key.go
  • pkg/session/allocator.go
  • pkg/memory/jsonl.go
  • pkg/memory/migration.go
  • pkg/agent/instance.go
  • pkg/agent/agent_utils.go
  • pkg/agent/agent_message.go
  • pkg/routing/route.go

若想了解会话维度配置与 launcher 的关系,可进一步阅读 docs/architecture/README.md 与 config/example.json;会话维度的默认值验证测试见 pkg/config/config_test.go 中的TestDefaultConfig_SessionDimensions

【免费下载链接】picoclawTiny, Fast, and Deployable anywhere — automate the mundane, unleash your creativity项目地址: https://gitcode.com/gh_mirrors/pi/picoclaw

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询