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/session、pkg/memory、pkg/agent三层的核心链路,理解会话维度的分配规则、崩溃恢复语义与迁移回退机制,并能在实际部署中正确配置session.dimensions与 dispatch 规则。本文内容基于当前仓库 docs/architecture/session-system.zh.md,不讨论 web/backend/middleware 中 launcher 登录 Cookie 或 dashboard 鉴权 session。
Session 系统的四大职责
Session 系统在 PicoClaw 运行时中承担四件核心事务:
- 决定消息归属:判断哪些消息应当共享同一段上下文(同一段历史记忆);
- 持久化上下文:让这段上下文能够跨 turn、跨进程重启持续存在;
- 抽象存储接口:向 agent loop 暴露一个足够小的
SessionStore接口,屏蔽底层后端差异; - 兼容旧格式:在存储层与路由层迁移期间,继续兼容旧版 session key(
agent:...形式)。
主要组件总览
整个会话链路由五个层次构成,从抽象的接口定义到具体的磁盘文件:
| 层次 | 文件 | 作用 |
|---|---|---|
| Session 抽象 | pkg/session/session_store.go | 定义 agent loop 依赖的SessionStore接口。 |
| 旧后端 | pkg/session/manager.go | 每个 session 一个 JSON 文件的旧实现,仍作为回退方案保留。 |
| Session 适配层 | pkg/session/jsonl_backend.go | 把pkg/memory.Store适配成SessionStore,并支持 alias 与 scope metadata。 |
| 持久化存储 | pkg/memory/jsonl.go | Append-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):
| 字段 | 含义 |
|---|---|
Version | Scope 模式版本,当前为ScopeVersionV1(值为1)。 |
AgentID | 处理该 turn 的路由 agent。 |
Channel | 归一化后的入站 channel 名称(为空时回退为unknown)。 |
Account | 归一化后的 bot / account 标识。 |
Dimensions | 当前启用的隔离维度顺序,例如chat或sender。 |
Values | 每个维度对应的具体归一化值。 |
Allocator 当前只识别四个维度:
spacechattopicsender
默认配置是按 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) - legacy
agent:...key(IsLegacyAgentSessionKey)
分配流程:从入站消息到会话读写
普通入站消息的完整链路如下:
InboundMessage -> RouteResolver.ResolveRoute(...) -> session.AllocateRouteSession(...) -> resolveScopeKey(...) -> ensureSessionMetadata(...) -> AgentLoop turn 执行 -> SessionStore 读写具体来说:
- pkg/agent/agent_message.go 先用归一化后的 inbound context 解析 agent route;
session.AllocateRouteSession(pkg/session/allocator.go#L32-L43)把 route 的SessionPolicy和 inbound context 组合成结构化SessionScope;- Allocator 会生成四类产物(
Allocation结构,pkg/session/allocator.go#L14-L20):SessionKey:当前路由会话的 canonical keySessionAliases:该路由会话的兼容 aliasMainSessionKey:agent 级主会话 key(由 legacy main alias 哈希而来)MainAliases:主会话对应的 legacy alias(即agent:<agent>:main)
runAgentLoop通过ensureSessionMetadata(pkg/agent/agent_utils.go#L399-L410)持久化 scope metadata 和 alias;- 后续读写时,
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),包含Dimensions与IdentityLinks。其取值逻辑在 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 键 | 含义 |
|---|---|---|
Key | key | 原始 session key |
Summary | summary | 会话摘要 |
Skip | skip | 逻辑截断偏移(被跳过的行数) |
Count | count | 已记录的消息行数 |
CreatedAt | created_at | 创建时间 |
UpdatedAt | updated_at | 更新时间 |
Scope | scope | 结构化 scope 的原始 JSON(pkg/memory保持与上层解耦) |
Aliases | aliases | legacy 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)原子替换;SetHistory和Compact都先写 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 后端,启动过程如下:
- 创建
memory.NewJSONLStore(dir); - 执行
memory.MigrateFromJSON(...)(pkg/memory/migration.go#L31),把旧.jsonsession 迁入新格式; - 用
session.NewJSONLBackend(store)包装; - 如果 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 结束时销毁(Save与Close均为空实现)。这样 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),仅供参考