
1. 会话层到底在DSH内核里扮演什么角色很多人第一次听到内核这个词脑子里浮现的都是操作系统那一套——进程调度、内存管理、中断处理。但DSH内核不是操作系统内核它是一个Agent运行时的内核。这两者的差别很大操作系统内核管的是硬件资源和进程隔离而DSH内核管的是一次对话从开始到结束的完整生命周期。会话层就是这套生命周期管理的中枢。我在最初设计DSH内核的时候犯过一个很典型的错误把会话理解成一个对话ID加一个消息列表。这个理解在Demo阶段完全够用但一旦你要支持多轮工具调用、上下文压缩、断点恢复、多Agent协作这个模型立刻就崩了。原因很简单——会话不是一个静态容器它是一个有状态、有生命周期、有事件流的运行时实体。打个比方。你可以把会话想象成一家餐厅的一桌客人。这桌客人什么时候来会话创建、点了什么菜消息与工具调用、中途加了几次菜多轮交互、有没有人中途离席又回来断线重连、最后什么时候结账走人会话结束——这些全部都是会话层要记录和管理的。如果你只记一个桌号和菜单那服务员根本没法知道这桌客人现在处于什么状态。DSH内核的会话层要解决的核心问题我归纳为四条状态持久化会话不能只活在内存里。进程重启、服务迁移、用户断线会话状态必须能恢复。事件可追溯每一次消息、每一次工具调用、每一次状态变更都要有不可篡改的记录方便回放和调试。上下文可压缩对话轮次多了Token会爆。会话层要负责在合适的时机做摘要和裁剪。并发可隔离多个会话同时跑彼此不能串数据资源要能独立计量。这四条里最容易被低估的是第二条——事件可追溯。我见过不少Agent项目会话状态只存一个最终结果中间过程全丢了。一旦线上出问题你根本不知道是哪一步工具调用返回了脏数据还是模型自己跑偏了。所以DSH内核从第四篇开始会话层就采用了**事件日志Event Log**作为唯一真相来源状态是事件的投影而不是反过来。这个设计决策影响深远。它意味着会话的每一次变更都是一条追加写入的事件而不是对某个状态对象的原地修改。追加写入天然适合JSONL这种格式——每行一个JSON对象写完就落盘不需要读-改-写整个文件。这也是为什么关键词里会出现JSONL它不是随便选的而是事件日志模型下最自然的存储形态。理解了这一层你再看会话这个词就不会觉得它只是个ID了。它是DSH内核里承载一切运行时状态的骨架是Agent从能跑到能稳定跑的分水岭。2. 为什么我最终选了JSONL而不是数据库会话存储的选型我前前后后试过三种方案最后才落到JSONL上。这个过程值得展开讲因为很多人在这一步会直接上数据库然后被复杂度反噬。2.1 三种存储方案的实测对比最开始我用的是纯内存字典。会话ID做key会话对象做value。开发阶段爽得不行读写都是纳秒级。但问题来得也快进程一重启所有会话全没了。对于本地跑的Agent工具用户关掉终端再打开历史对话消失体验直接崩盘。这个方案只适合一次性脚本不适合任何需要记住的场景。第二个方案是SQLite。单文件、零配置、支持事务看起来是完美选择。我确实用它跑了一段时间但很快发现两个痛点。第一会话事件是高频追加写入SQLite虽然支持WAL模式但在高并发写入下还是会出现锁竞争尤其是多个会话同时活跃的时候。第二也是最要命的——调试极其痛苦。我想看某个会话的原始事件流得写SQL查询还得处理各种字段映射。而Agent开发阶段我需要频繁地肉眼检查事件序列SQLite的二进制格式让这件事变得很别扭。第三个方案就是JSONL。每行一个独立JSON对象追加写入天然支持流式读取。我用它替换SQLite之后调试体验直接起飞——tail -f session.jsonl就能实时看事件流grep就能过滤特定类型的事件连日志分析工具都不用装。2.2 JSONL在会话场景下的三个硬优势第一个优势是追加写入的原子性。在POSIX文件系统上对同一个文件描述符的追加写入只要单行不超过PIPE_BUF通常是4096字节就是原子的。这意味着多个进程或线程可以安全地往同一个会话文件追加事件不需要额外的锁。当然实际实现里我还是加了文件锁因为单行事件可能超过4KB但至少大部分情况下不需要担心交错写入。第二个优势是崩溃恢复的天然友好。如果进程在写入过程中崩溃JSONL文件最多只会有一行不完整的记录。恢复的时候解析器遇到不完整的行直接跳过就行前面的数据全部完好。而如果用二进制格式或者需要维护索引的数据库崩溃恢复就要复杂得多。第三个优势是与事件溯源模式的完美契合。事件溯源的核心思想是不存状态只存事件状态由事件重放得出。JSONL的追加特性天然就是事件日志的形态。每次会话状态变更我只需要append一条事件不需要读取现有状态再修改。这让写入路径变得极简也避免了并发修改状态时的竞态问题。2.3 我踩过的JSONL坑当然JSONL不是银弹。我踩过的坑主要有三个。坑一文件无限增长。一个活跃会话跑几天事件文件能到几百MB。解决方案是定期做快照Snapshot把当前状态序列化成一条特殊事件然后截断之前的事件。恢复时先加载最近的快照再重放快照之后的事件。坑二并发读取时的部分行问题。一个进程正在写第N行另一个进程读到了半行。解决方案是读取端做行完整性校验——尝试解析JSON失败就丢弃最后一行等下次读取。坑三时间戳精度。早期我用秒级时间戳结果同一秒内的多个事件排序不稳定。后来改成毫秒级并且加了一个单调递增的序列号作为二级排序键彻底解决了乱序问题。下面是我实际使用的会话事件结构你可以直接参考{ seq: 1024, ts: 1735689600123, session_id: sess_a1b2c3d4, type: message.appended, payload: { role: assistant, content: 我来帮你查一下天气。, tool_calls: [ { id: call_xyz, name: get_weather, arguments: {city: 杭州} } ] }, meta: { parent_seq: 1023, trace_id: trace_789 } }这个结构里seq是单调递增的序列号ts是毫秒时间戳type是事件类型payload是事件负载meta放追踪信息。所有事件共享这个外壳具体类型通过type字段区分。这种设计让事件解析器可以统一处理外壳只在payload层面做类型分发。3. 会话状态机从创建到销毁的完整链路会话不是一个静态对象它有自己的生命周期。我在DSH内核里把会话定义成一个状态机状态之间的转换由事件驱动。这个设计让会话的行为变得可预测、可测试、可恢复。3.1 五个核心状态与转换条件DSH内核的会话有五个状态状态含义进入条件离开条件CREATED会话已创建尚未激活收到创建请求首条消息写入ACTIVE会话活跃可接收消息首条消息写入空闲超时或显式挂起SUSPENDED会话挂起保留状态空闲超时或用户挂起收到恢复请求COMPACTING正在做上下文压缩触发压缩阈值压缩完成CLOSED会话已关闭用户关闭或超时销毁不可逆状态转换不是随便设计的每个转换背后都有实际场景支撑。比如ACTIVE到SUSPENDED的转换是为了解决用户开了会话但长时间不发言的问题。如果不挂起这些空闲会话会一直占用内存和连接资源。挂起之后会话状态落盘内存释放等用户回来再恢复。COMPACTING这个状态比较特殊它是一个瞬态。当会话的Token数超过阈值内核会触发压缩把早期对话做摘要替换掉原始消息。这个过程需要调用模型是异步的所以需要一个独立状态来标记这个会话正在压缩暂时不要写入新消息。3.2 状态转换的事件驱动实现在DSH内核里状态转换不是直接修改一个state字段而是通过追加事件来触发。比如用户发了一条消息内核会追加一条message.appended事件然后状态机根据当前状态和事件类型决定是否转换状态。这种设计的好处是状态转换逻辑集中在一处不会散落在各个业务代码里。我实现了一个状态转换表大致长这样TRANSITIONS { (CREATED, message.appended): ACTIVE, (ACTIVE, session.suspended): SUSPENDED, (SUSPENDED, session.resumed): ACTIVE, (ACTIVE, compaction.started): COMPACTING, (COMPACTING, compaction.finished): ACTIVE, (ACTIVE, session.closed): CLOSED, (SUSPENDED, session.closed): CLOSED, }每次事件追加后状态机查表决定下一个状态。如果查不到对应的转换说明这个事件在当前状态下非法内核会拒绝并记录一条警告。这个机制帮我抓到了不少逻辑bug——比如在CLOSED状态下还试图追加消息这在早期版本里经常发生。3.3 恢复流程从事件日志重建会话会话恢复是状态机设计价值的集中体现。当进程重启或者会话从挂起恢复时内核需要从JSONL事件日志重建会话状态。流程是这样的读取会话文件找到最近的快照事件如果有。从快照事件恢复基础状态。重放快照之后的所有事件依次应用到状态机上。重建完成后会话进入ACTIVE或SUSPENDED状态取决于最后一条事件。这个过程听起来简单但有个细节很容易出错重放必须是幂等的。也就是说同一个事件重放两次结果应该一样。这就要求事件本身携带足够的信息不能依赖外部可变状态。我早期有个事件类型叫counter.increment重放的时候会把计数器加两次导致数据错乱。后来改成counter.set携带绝对值问题才解决。提示如果你也在做事件溯源记住一条铁律——事件描述的是发生了什么不是要做什么。前者是事实后者是命令事实可以重放命令重放会出问题。4. 上下文压缩会话层最容易被做砸的地方会话跑久了上下文会越来越长。模型有Token上限不可能无限塞。所以会话层必须做上下文压缩。这件事看起来简单——把老消息删掉或者摘要一下——但实际做起来坑非常多。我在DSH内核里迭代了三版压缩策略才找到一个相对稳定的方案。4.1 为什么直接截断是最差的选择最朴素的压缩策略是滑动窗口只保留最近N条消息老的直接丢弃。这个方案实现简单但效果很差。原因在于对话的早期往往包含关键信息——用户的目标、约束条件、已经确认的事实。这些信息一旦丢失模型就会开始胡言乱语反复问已经回答过的问题。我实测过一个场景用户让Agent帮忙订机票前面说了出发地、目的地、日期然后聊了十几轮细节最后说就按刚才说的订。如果早期消息被截断Agent根本不知道刚才说的是什么。这种体验是灾难性的。所以DSH内核的压缩策略从一开始就排除了纯截断而是采用分层压缩。4.2 分层压缩的具体做法分层压缩的核心思想是不同重要性的信息用不同的方式保留。系统提示与约束永远保留原文不压缩。这些是会话的宪法。用户目标与关键事实抽取成结构化摘要放在上下文顶部。工具调用结果只保留摘要和关键字段原始大段输出丢弃。普通对话轮次按时间衰减老的做摘要新的保留原文。具体实现上我维护了一个压缩水位线。当Token数超过阈值比如模型上限的70%触发压缩。压缩时从最老的消息开始逐条判断类型按上述规则处理。处理完一批后重新计算Token数如果还是超继续下一批。这里有个关键细节压缩不能破坏工具调用的配对关系。一次工具调用包含请求和结果两条消息如果只压缩了请求保留了结果模型会看到一条孤立的工具结果直接报错。所以压缩的最小单位是一个完整的交互轮次而不是单条消息。4.3 压缩触发时机的权衡压缩触发太早浪费计算资源还会丢失本可以保留的细节。触发太晚可能来不及压缩就超限了。我最终选的阈值是模型上下文窗口的70%留30%的缓冲。为什么是70%因为压缩本身也要消耗Token——摘要生成需要把待压缩内容喂给模型。如果等到90%才触发压缩请求本身可能就超限了。70%是一个经验值实测下来比较稳。当然这个值可以根据模型窗口大小调整窗口越大缓冲比例可以越小。还有一个容易被忽略的点压缩是异步的。压缩过程中会话不能接收新消息否则会和新状态冲突。这就是前面提到的COMPACTING状态的用途。压缩期间新消息进入队列等压缩完成后统一处理。5. 多会话并发下的隔离与资源计量单会话跑通之后下一个挑战是多会话并发。DSH内核要同时服务多个会话每个会话有自己的上下文、自己的工具调用、自己的Token消耗。如果隔离没做好会出现会话串数据、资源互相挤占的问题。5.1 会话隔离的三个层次我在DSH内核里做了三层隔离第一层是数据隔离。每个会话有独立的JSONL文件独立的目录。会话之间不共享任何可变状态。这条看起来是常识但实际实现时很容易违反——比如用一个全局的当前会话变量多线程下立刻串数据。我的做法是所有会话相关操作都必须显式传入会话ID禁止任何隐式的全局状态。第二层是执行隔离。每个会话的工具调用在独立的执行上下文中运行。这意味着一个会话的工具调用超时或崩溃不会影响其他会话。实现上我用了一个会话级的任务队列每个会话的请求串行处理会话之间并行。第三层是资源隔离。每个会话有独立的Token预算、独立的并发限制。一个会话不能无限消耗资源把其他会话饿死。这个后面细说。5.2 Token预算与配额管理Token是Agent运行的核心资源。DSH内核给每个会话分配一个Token预算超出预算的请求会被拒绝或降级。预算管理分两个维度单次请求预算和会话总预算。单次请求预算限制一次模型调用的最大Token数防止单次调用失控。会话总预算限制整个会话生命周期内的累计消耗防止某个会话无限跑下去。实现上我在会话状态里维护一个token_usage字段每次模型调用后累加。当累计值接近预算上限时内核会发出警告事件并开始拒绝非必要的调用。这个机制在实测中救过我好几次——有一次一个会话因为工具调用陷入循环Token消耗飙升预算机制及时把它掐断了。5.3 并发写入的锁策略多个会话并发写入时锁的粒度很关键。锁太粗性能差锁太细容易出竞态。我的策略是按会话分锁。每个会话有一个独立的写锁不同会话之间不竞争。同一个会话内的写入串行化保证事件顺序。这个策略下并发度取决于活跃会话数而不是总会话数扩展性很好。具体实现上我用了一个SessionLockManager按会话ID维护锁对象。获取锁时如果会话锁不存在就创建存在就复用。会话关闭时锁对象被回收。这个管理器本身需要一把全局锁来保护锁的创建和回收但这把锁的持有时间极短不构成瓶颈。注意按会话分锁的前提是会话之间确实没有共享的可变状态。如果你在会话之外还有全局状态那分锁就不够了需要额外的同步机制。这也是为什么我一直强调数据隔离要先做好。6. 事件日志的查询与回放调试利器会话层做完之后我发现最大的收益不是功能而是调试能力。事件日志让回放成为可能而回放是排查Agent问题的最强手段。6.1 用事件日志复现线上问题线上出问题时用户只会说它回答错了。但错在哪一步是模型理解错了还是工具返回了脏数据还是上下文被压缩掉了关键信息没有事件日志你只能猜。有了事件日志你可以把整个会话的事件流拉出来逐条重放精确定位问题。我常用的排查流程是这样的从用户反馈拿到会话ID。拉取该会话的JSONL文件。用回放工具重放事件观察每一步的状态变化。定位到异常事件检查其payload。如果是模型问题把当时的上下文单独拿出来复现。这个流程把玄学问题变成了确定性排查。我印象最深的一次用户反馈Agent反复问同一个问题。回放之后发现是上下文压缩把用户的关键约束摘要错了导致模型以为约束不存在。定位到具体是哪次压缩出的问题后我调整了摘要提示词问题解决。6.2 回放工具的实现要点回放工具的核心是事件重放器。它读取JSONL文件逐条应用事件到状态机输出每一步的状态快照。实现上有几个要点支持断点可以从任意seq开始重放不用从头来。支持过滤只看特定类型的事件比如只看工具调用。支持对比把两次回放的结果做diff看差异在哪。我用Python写了一个简单的回放器核心逻辑不到200行但极大提升了调试效率。它不需要任何外部依赖读JSONL、应用状态机、打印结果就这么简单。6.3 事件日志的隐私与安全考量事件日志记录了会话的全部内容包括用户输入、模型输出、工具调用参数。这些数据可能包含敏感信息。DSH内核在写入事件日志时做了两层处理第一层是字段级脱敏。对于已知的敏感字段比如API密钥、密码在写入前替换成占位符。这需要在事件构造阶段就处理不能等到写入时再过滤否则敏感数据已经进了内存。第二层是访问控制。事件日志文件只有会话所有者能读其他会话和用户无权访问。这个在文件系统层面用权限控制在应用层面用会话ID校验。提示脱敏一定要在事件构造阶段做不要依赖写入时的过滤。因为事件可能在内存里被其他地方引用写入时过滤已经晚了。我早期就犯过这个错敏感数据在内存里被日志模块意外打印出来。7. 我在会话层实现中踩过的五个坑前面讲的都是设计层面的东西这一节讲实操中真正让我掉头发的问题。这些坑在文档里基本不会写但每一个都真实存在。7.1 坑一时间戳回拨导致事件乱序有一次线上出现会话状态错乱排查发现是服务器时间被NTP校正时间戳回拨了几秒。结果新事件的ts比老事件还小按时间排序就乱了。解决方案是不依赖时间戳排序改用单调递增的seq。seq由会话内的计数器生成只增不减不受系统时间影响。时间戳只作为辅助信息用于展示和粗粒度分析。7.2 坑二JSON序列化的浮点精度问题JSON标准对浮点数精度没有强制要求不同语言的序列化实现可能产生不同的结果。我遇到过Python序列化的0.1在另一个服务里反序列化后变成0.10000000000000001导致事件对比失败。解决方案是对浮点数做统一处理。要么转成字符串要么限定小数位数。我在事件序列化时统一把浮点数格式化成固定小数位避免精度漂移。7.3 坑三大payload导致单行超限前面提到JSONL的原子追加依赖单行不超过PIPE_BUF。但工具调用结果可能很大比如一次网页抓取返回几百KB的HTML。这种大payload直接写入单行远超4KB原子性就没了。解决方案是大payload外置。超过阈值比如8KB的payload单独存一个文件事件里只存文件引用。这样事件行保持小体积原子性有保障。7.4 坑四会话文件句柄泄漏每个活跃会话持有一个文件句柄用于追加写入。会话多了之后文件句柄耗尽新会话创建失败。排查发现是会话关闭时没有正确释放句柄。解决方案是用上下文管理器管理句柄。所有文件操作都通过with语句确保异常时也能释放。同时加了一个句柄池限制同时打开的文件数超出时按LRU淘汰。7.5 坑五压缩与写入的竞态压缩是异步的写入是同步的。如果压缩过程中有新消息写入可能压缩的是旧状态写入的是新状态两者冲突。解决方案是压缩期间冻结写入。进入COMPACTING状态后新消息进入队列不直接写入。压缩完成后先应用压缩结果再处理队列中的消息。这个机制保证了压缩和写入不会交叉。8. 会话层后续可以怎么演进会话层目前能稳定支撑单机多会话场景但还有几个方向可以继续做。第一个方向是分布式会话。现在会话状态存在本地文件如果要做多机部署需要把事件日志放到共享存储或者做一个会话路由层把同一会话的请求固定路由到同一节点。这个方向的关键是保持事件日志的追加语义分布式环境下要用支持追加的存储。第二个方向是会话分支。现在会话是线性的但实际场景里用户可能想从某个点重新开始。如果事件日志支持分支就可以从任意seq分叉出新的会话线原线保留。这对A/B测试和探索性交互很有价值。第三个方向是跨会话记忆。现在每个会话是独立的但用户可能希望Agent记住跨会话的信息。这需要在会话层之上加一个记忆层把关键事实抽取出来跨会话共享。这个方向要小心隐私和隔离问题不能简单地把所有会话数据混在一起。第四个方向是事件日志的压缩存储。JSONL可读性好但存储效率低。可以考虑定期把老事件转成列式存储查询时再解压。这样既保留了可读性又降低了存储成本。会话层是DSH内核里我最满意的部分之一不是因为它复杂而是因为它简单且正确。事件溯源加JSONL的组合让整个系统变得可理解、可调试、可恢复。如果你也在做Agent内核我强烈建议从会话层开始把事件日志做扎实后面的功能都会受益。