事件怎么到浏览器SSE 还是 WebSocketClaude Code 的输出端是终端渲染器Ink React事件通过 AsyncGenerator 直接传给 React 组件没有网络传输这一步。V2 需要一套从服务端到浏览器的流式协议。两个选项WebSocket 还是 SSEWebSocket 双向通信能力更强。但 V2 的交互模式本质是单向的服务端推送流式结果客户端只在初始请求和点停止时发信号。SSE 更轻原生支持断线重连Hono 内置支持不需要额外库。因此选了 SSE。对于前端接收方式浏览器有EventSourceAPI原生处理 SSE但它不支持自定义 headers加不了 JWT token。所以用fetch ReadableStream手动解析 SSE 字节流。多写了点代码但认证链路不需要妥协。线缆格式很直接event: text data: {content: ...} event: tool_result data: {tool_use_id:xxx,content:...,is_error:false}定义了 10 种事件类型覆盖对话全部状态事件触发时机负载textLLM 输出文本 token{ content: string }content_block_startLLM 开始输出 tool_use{ tool_use_id, tool_name }content_block_deltatool_use 参数流式增量{ tool_use_id, input }content_block_stoptool_use 参数接收完毕{ tool_use_id }tool_resultTool 执行完成{ tool_use_id, content, is_error }usage每轮 LLM 调用结束{ input_tokens, output_tokens, cache_* }doneAgent 循环正常结束{}error循环异常{ message }user中断插入的消息{ content }user_questionAskUserQuestion 触发{ tool_use_id, questions[] }一轮 query() 调用中事件的典型时序时间 → text ─── text ─── content_block_start ─── content_block_delta* ─── content_block_stop ─── tool_result ─── usage ─── done (LLM 思考) (开始调 Tool) (参数逐个到达) (参数完整) (执行结果) (token 统计) (结束) 中间可能穿插多轮 ... ─── content_block_start ─── content_block_delta* ─── tool_result ─── text ─── content_block_start ─── ... (第二轮 Tool 调用) (LLM 基于结果继续分析)事件命名有个小插曲。最初是沿用了V1版本中类似消息类型的名字后来翻 Claude Code 源码发现它的事件名更简洁text、tool_use、tool_result就统一过去了。多用户多会话状态怎么隔离SSE 把事件推到了浏览器但一个用户可能同时开着好几个Session也就是对话。Claude Code 也有多会话——一个进程里切来切去、--resume恢复历史对话。但那是单用户的所有会话共享同一个文件系统和权限上下文。V2 是 Web 应用多个用户同时在线各自的会话各自跑谁也不能看到别人的消息、串到别人的 SSE 流里。Zustand store 按 sessionId 分区。每个会话的消息、SSE 连接状态、工具执行状态都存在独立的分区里。用户从会话 A 切到 B 时A 的 SSE 流在后台继续跑到结束不会因为切走了就被强行终止。B 的消息从独立分区加载不会和 A 的数据串到一起。这个设计踩过一个坑切换会话时 SSE 事件串到了另一个会话里。排查发现是 store 没有严格按 sessionId 隔离收到 SSE 事件后直接往当前会话的数组里 push没检查事件的 sessionId 和当前显示的 sessionId 是否一致。修完之后加了分区边界检查tool_use的 running 状态也按 sessionId 独立追踪。切会话后后台流继续跑的设计有一个副作用后台流跑完最后一轮时 tool_result 事件没有地方显示。用户切回来时看到的是完整结果看不到工具执行的中间状态。体验不够好但业务侧的对话轮数通常在 3 到 5 轮后台跑完很快。没做更复杂的恢复逻辑属于知道不完美但优先级不够的范畴。打断对话中断不只是 abort多会话跑起来了用户点了停止按钮要怎么办这件事就绕不开了。Claude Code 的中断通过 AbortController 实现调用abort()终止 LLM 流式请求清理当前轮次的中间状态。V2 延续了这个机制但 Web 场景多了一个硬要求前端要给即时反馈。用户点了停止不能等后端处理完才更新 UI那体验太差了。前端的设计点停止瞬间立即将所有 running 状态的 tool_use 标记为 error、清除 streaming 状态。用户点下去就看到 UI 反应。后端的中断是异步完成的前端不用等。AbortController 存在sessionAbortControllers这个 Map 里按键是 sessionId。中断触发后后端的处理链路如下用户点停止 │ ▼ POST /api/sessions/:id/interrupt │ ▼ abortController.abort(user_interrupt) │ ▼ query() 循环内 5 个检查点按执行时序 │ ├──[1] 每一轮循环开始前 │ 如果已中断 → yield user 消息return │ ├──[2] LLM 流式处理期间每收到一个事件都检查 │ 正在接收 tool_use → 为当前 已收集的全部 tool_use 生成 synthetic tool_result │ ├──[3] LLM 调用抛错后的 catch 块 │ 区分LLM 自己报错还是中断导致抛错 → 后者走 synthetic tool_result 逻辑 │ ├──[4] 工具执行前 │ LLM 返回了 tool_use 但还没执行 → 为所有 tool_use 生成 synthetic tool_result │ └──[5] 工具执行期间 Promise.race([executionPromise, abortPromise]) → 中断胜出 → synthetic tool_resultsynthetic tool_result 的核心作用假设 LLM 刚返回了 3 个 tool_use 块读文件、查数据库、写报告用户点了停止。这 3 个 tool_use 成了孤儿——LLM 发出了指令但没有 tool_result 返回给它。如果下一轮对话以这个不一致的状态开始LLM 会困惑上一轮它要求执行了 3 个操作为什么没有结果解决办法为每个孤儿tool_use 生成一个占位结果内容为[工具执行已被用户中断]标记为 error。下一轮对话开始时消息列表是自洽的每个 tool_use 都有对应的 tool_result虽然结果是中断但状态一致。数据持久化消息、元数据、大文件各放各的SSE 流通了消息到了前端但一刷新就没了。Claude Code 的消息持久化依托于 transcript 文件。进程活着时在内存里进程退出时写进文件。这是单机 CLI 的心智模型。Web 用户随时可能刷新页面或关闭浏览器多用户系统也不能假设只有一个进程。面对一堆风格不同的数据我选择了三种存储机制机制存什么写入模式查询方式集群迁移方向JSONL会话消息append-only一行一条逐行加载按 sessionId 定位文件sticky session 亲和SQLite用户/项目/会话/配置元数据CRUDWAL 模式SQL 筛选/排序/JOIN迁 PostgreSQL/MySQL磁盘文件上传文件、大工具结果一次写入按 projectId 分目录路径直读迁 MinIO/S3JSONL 存消息内容消息的写入模式很单一流式追加。LLM 每吐一个字、每个 tool_result 产生都是 append 到末尾。没有修改、没有删除、没有复杂查询。JSONL 天然匹配这个模式每行一个 JSON 对象文件路径data/messages/{sessionId}.jsonlappend 就完事不需要事务、建索引、migration。读的时候逐行加载解析成本比 SQL 查询低。删除会话时直接删文件和生命周期绑定。SQLite 存结构化元数据用户、项目、会话、文件元信息、技能注册表、LLM 配置、MCP 服务器配置、系统配置。这些数据需要关联查询、筛选、排序。比如某用户启用了哪些技能要 JOIN 两张表文件系统做这件事要么遍历所有文件要么自己维护索引。这正是关系型数据库最擅长的。为什么是 SQLite 而不是 MySQL 或 PostgreSQL最直接的理由是零运维。Bun 内置bun:sqlite编译进二进制没有独立进程、没有连接字符串、没有端口。WAL 模式下的并发读性能不差单文件备份和恢复极其简单。但还有一个往前看的考虑为集群部署预留迁移路径。SQLite 的局限很清楚只有一个写入者做不到多实例共享。但 SQLite 的 schema 设计可以按关系型数据库的方式做——建表、建索引、理清关联关系。将来需要多实例部署时这些表结构迁移到 PostgreSQL 或 MySQL 直接能用不用重新设计数据模型。当前用 SQLite但不是将就着用而是按标准设计当前用最简单的实现。哪些数据适合关系型数据库哪些适合文件系统这个划分靠数据的访问模式决定不靠直觉。适合关系型数据库的用户、项目、会话、文件元信息、技能注册表、用户技能禁用记录、LLM 配置、MCP 服务器配置、系统配置。共同特征结构化字段、需要条件筛选和关联查询、写入频率低、需要事务保证一致性。适合文件系统的消息 JSONL、上传文件、工具执行结果、技能 SKILL.md 正文。共同特征体积大、顺序读写、不需要复杂查询、按 ID 或路径直接定位。上传文件是二进制大对象放数据库里会让备份和迁移变得非常重。技能 SKILL.md 有一个额外的理由LLM 运行时通过 Read 工具直接读文件系统放数据库里要多一层转换。集群化文件系统怎么做目前没实现但分析过可行方向。四个方案按复杂度从低到高方案一共享存储挂载。所有实例挂同一个 NAS/NFS 卷data/目录指向共享路径。最简单但 NAS 本身是单点网络延迟对 JSONL 频繁 append 的影响需要实测。方案二对象存储。上传文件和工具结果迁到 MinIO 或 S3。适合大文件但对 JSONL 的逐行追加语义支持不好需要额外封装。方案三分布式文件系统。GlusterFS 或 Ceph多节点冗余、自动分片。运维负担重以当前的业务体量杀鸡用牛刀。方案四应用层会话亲和。不做文件系统层面的集群化而是让同一个 sessionId 的请求始终路由到同一个实例。Sticky session 把问题从文件系统怎么共享变成路由怎么亲和零存储改造。代价是实例宕机时该会话暂时不可用但恢复后文件在本地磁盘上不会丢。当前业务体量下这个方案最务实。当前是单实例不需要集群化文件系统。但四个方向在架构设计时都考虑过确保将来不会因为数据放文件系统所以改不了卡住。已存储的关系型数据当前 SQLite 有 9 张表统一用aac_前缀命名表名用途aac_users用户账号用户名、密码哈希、角色、启用状态、个人配置aac_projects项目归属用户、名称。一个用户可以有多个项目aac_sessions会话归属项目、名称。消息内容在 JSONL 文件里这里只存会话元信息aac_files上传文件元信息原始文件名、存储名、类型、大小、预览数据。实际文件在磁盘aac_skill_registry技能注册表技能名、展示名、描述、来源、scope、启用状态aac_user_disabled_global_skills用户技能禁用记录哪个用户禁用了哪个全局技能aac_llm_configsLLM 配置API 地址、加密 token、模型列表、当前激活的模型aac_mcp_serversMCP 服务器配置名称、连接配置 JSON、工具缓存、启用状态aac_sys_config系统配置全局开关如 LLM 日志模式、工具展示模式、缓存策略前四张是核心业务链路用户→项目→会话→文件。后面五张是配置和能力扩展分别对应技能系统、MCP 集成、LLM 接入和系统级开关。表之间的关系aac_users (1) ────── (N) aac_projects (1) ────── (N) aac_sessions │ │ │ └── (N) JSONL 消息文件 │ (data/messages/{sessionId}.jsonl) │ └── (N) aac_files │ └── (N) 磁盘文件 (data/projects/{projectId}/files/) aac_skill_registry (N) ──── (N) aac_user_disabled_global_skills │ │ └── (N) 磁盘文件 └── 用户级禁用开关 (data/skills/{name}/SKILL.md) aac_sys_config ─── 独立配置项无外键关联 aac_llm_configs ── 独立配置项运行时按 is_active 选择 aac_mcp_servers ── 独立配置项启动时初始化连接核心链路是两条业务链和能力链。业务链用户 → 项目 → 会话 → 文件的设计思路用户登录后首先面对的是项目列表。一个项目对应一个业务场景——比如2025年度审计、供应商对账。项目是文件的上传边界和会话的组织容器上传 Excel 对账单到项目 A在项目 B 的会话里是看不到这些文件的。会话挂在项目下一个项目可以有多个会话——可能是一次分析任务的完整对话也可能拆成数据清洗分析写报告三个独立会话取决于用户的习惯。消息 JSONL 跟着会话走删除会话时对应的消息文件一并清理。
Cloud Agent 开发笔记(3):Web 交互与数据持久化
发布时间:2026/6/28 2:14:47
事件怎么到浏览器SSE 还是 WebSocketClaude Code 的输出端是终端渲染器Ink React事件通过 AsyncGenerator 直接传给 React 组件没有网络传输这一步。V2 需要一套从服务端到浏览器的流式协议。两个选项WebSocket 还是 SSEWebSocket 双向通信能力更强。但 V2 的交互模式本质是单向的服务端推送流式结果客户端只在初始请求和点停止时发信号。SSE 更轻原生支持断线重连Hono 内置支持不需要额外库。因此选了 SSE。对于前端接收方式浏览器有EventSourceAPI原生处理 SSE但它不支持自定义 headers加不了 JWT token。所以用fetch ReadableStream手动解析 SSE 字节流。多写了点代码但认证链路不需要妥协。线缆格式很直接event: text data: {content: ...} event: tool_result data: {tool_use_id:xxx,content:...,is_error:false}定义了 10 种事件类型覆盖对话全部状态事件触发时机负载textLLM 输出文本 token{ content: string }content_block_startLLM 开始输出 tool_use{ tool_use_id, tool_name }content_block_deltatool_use 参数流式增量{ tool_use_id, input }content_block_stoptool_use 参数接收完毕{ tool_use_id }tool_resultTool 执行完成{ tool_use_id, content, is_error }usage每轮 LLM 调用结束{ input_tokens, output_tokens, cache_* }doneAgent 循环正常结束{}error循环异常{ message }user中断插入的消息{ content }user_questionAskUserQuestion 触发{ tool_use_id, questions[] }一轮 query() 调用中事件的典型时序时间 → text ─── text ─── content_block_start ─── content_block_delta* ─── content_block_stop ─── tool_result ─── usage ─── done (LLM 思考) (开始调 Tool) (参数逐个到达) (参数完整) (执行结果) (token 统计) (结束) 中间可能穿插多轮 ... ─── content_block_start ─── content_block_delta* ─── tool_result ─── text ─── content_block_start ─── ... (第二轮 Tool 调用) (LLM 基于结果继续分析)事件命名有个小插曲。最初是沿用了V1版本中类似消息类型的名字后来翻 Claude Code 源码发现它的事件名更简洁text、tool_use、tool_result就统一过去了。多用户多会话状态怎么隔离SSE 把事件推到了浏览器但一个用户可能同时开着好几个Session也就是对话。Claude Code 也有多会话——一个进程里切来切去、--resume恢复历史对话。但那是单用户的所有会话共享同一个文件系统和权限上下文。V2 是 Web 应用多个用户同时在线各自的会话各自跑谁也不能看到别人的消息、串到别人的 SSE 流里。Zustand store 按 sessionId 分区。每个会话的消息、SSE 连接状态、工具执行状态都存在独立的分区里。用户从会话 A 切到 B 时A 的 SSE 流在后台继续跑到结束不会因为切走了就被强行终止。B 的消息从独立分区加载不会和 A 的数据串到一起。这个设计踩过一个坑切换会话时 SSE 事件串到了另一个会话里。排查发现是 store 没有严格按 sessionId 隔离收到 SSE 事件后直接往当前会话的数组里 push没检查事件的 sessionId 和当前显示的 sessionId 是否一致。修完之后加了分区边界检查tool_use的 running 状态也按 sessionId 独立追踪。切会话后后台流继续跑的设计有一个副作用后台流跑完最后一轮时 tool_result 事件没有地方显示。用户切回来时看到的是完整结果看不到工具执行的中间状态。体验不够好但业务侧的对话轮数通常在 3 到 5 轮后台跑完很快。没做更复杂的恢复逻辑属于知道不完美但优先级不够的范畴。打断对话中断不只是 abort多会话跑起来了用户点了停止按钮要怎么办这件事就绕不开了。Claude Code 的中断通过 AbortController 实现调用abort()终止 LLM 流式请求清理当前轮次的中间状态。V2 延续了这个机制但 Web 场景多了一个硬要求前端要给即时反馈。用户点了停止不能等后端处理完才更新 UI那体验太差了。前端的设计点停止瞬间立即将所有 running 状态的 tool_use 标记为 error、清除 streaming 状态。用户点下去就看到 UI 反应。后端的中断是异步完成的前端不用等。AbortController 存在sessionAbortControllers这个 Map 里按键是 sessionId。中断触发后后端的处理链路如下用户点停止 │ ▼ POST /api/sessions/:id/interrupt │ ▼ abortController.abort(user_interrupt) │ ▼ query() 循环内 5 个检查点按执行时序 │ ├──[1] 每一轮循环开始前 │ 如果已中断 → yield user 消息return │ ├──[2] LLM 流式处理期间每收到一个事件都检查 │ 正在接收 tool_use → 为当前 已收集的全部 tool_use 生成 synthetic tool_result │ ├──[3] LLM 调用抛错后的 catch 块 │ 区分LLM 自己报错还是中断导致抛错 → 后者走 synthetic tool_result 逻辑 │ ├──[4] 工具执行前 │ LLM 返回了 tool_use 但还没执行 → 为所有 tool_use 生成 synthetic tool_result │ └──[5] 工具执行期间 Promise.race([executionPromise, abortPromise]) → 中断胜出 → synthetic tool_resultsynthetic tool_result 的核心作用假设 LLM 刚返回了 3 个 tool_use 块读文件、查数据库、写报告用户点了停止。这 3 个 tool_use 成了孤儿——LLM 发出了指令但没有 tool_result 返回给它。如果下一轮对话以这个不一致的状态开始LLM 会困惑上一轮它要求执行了 3 个操作为什么没有结果解决办法为每个孤儿tool_use 生成一个占位结果内容为[工具执行已被用户中断]标记为 error。下一轮对话开始时消息列表是自洽的每个 tool_use 都有对应的 tool_result虽然结果是中断但状态一致。数据持久化消息、元数据、大文件各放各的SSE 流通了消息到了前端但一刷新就没了。Claude Code 的消息持久化依托于 transcript 文件。进程活着时在内存里进程退出时写进文件。这是单机 CLI 的心智模型。Web 用户随时可能刷新页面或关闭浏览器多用户系统也不能假设只有一个进程。面对一堆风格不同的数据我选择了三种存储机制机制存什么写入模式查询方式集群迁移方向JSONL会话消息append-only一行一条逐行加载按 sessionId 定位文件sticky session 亲和SQLite用户/项目/会话/配置元数据CRUDWAL 模式SQL 筛选/排序/JOIN迁 PostgreSQL/MySQL磁盘文件上传文件、大工具结果一次写入按 projectId 分目录路径直读迁 MinIO/S3JSONL 存消息内容消息的写入模式很单一流式追加。LLM 每吐一个字、每个 tool_result 产生都是 append 到末尾。没有修改、没有删除、没有复杂查询。JSONL 天然匹配这个模式每行一个 JSON 对象文件路径data/messages/{sessionId}.jsonlappend 就完事不需要事务、建索引、migration。读的时候逐行加载解析成本比 SQL 查询低。删除会话时直接删文件和生命周期绑定。SQLite 存结构化元数据用户、项目、会话、文件元信息、技能注册表、LLM 配置、MCP 服务器配置、系统配置。这些数据需要关联查询、筛选、排序。比如某用户启用了哪些技能要 JOIN 两张表文件系统做这件事要么遍历所有文件要么自己维护索引。这正是关系型数据库最擅长的。为什么是 SQLite 而不是 MySQL 或 PostgreSQL最直接的理由是零运维。Bun 内置bun:sqlite编译进二进制没有独立进程、没有连接字符串、没有端口。WAL 模式下的并发读性能不差单文件备份和恢复极其简单。但还有一个往前看的考虑为集群部署预留迁移路径。SQLite 的局限很清楚只有一个写入者做不到多实例共享。但 SQLite 的 schema 设计可以按关系型数据库的方式做——建表、建索引、理清关联关系。将来需要多实例部署时这些表结构迁移到 PostgreSQL 或 MySQL 直接能用不用重新设计数据模型。当前用 SQLite但不是将就着用而是按标准设计当前用最简单的实现。哪些数据适合关系型数据库哪些适合文件系统这个划分靠数据的访问模式决定不靠直觉。适合关系型数据库的用户、项目、会话、文件元信息、技能注册表、用户技能禁用记录、LLM 配置、MCP 服务器配置、系统配置。共同特征结构化字段、需要条件筛选和关联查询、写入频率低、需要事务保证一致性。适合文件系统的消息 JSONL、上传文件、工具执行结果、技能 SKILL.md 正文。共同特征体积大、顺序读写、不需要复杂查询、按 ID 或路径直接定位。上传文件是二进制大对象放数据库里会让备份和迁移变得非常重。技能 SKILL.md 有一个额外的理由LLM 运行时通过 Read 工具直接读文件系统放数据库里要多一层转换。集群化文件系统怎么做目前没实现但分析过可行方向。四个方案按复杂度从低到高方案一共享存储挂载。所有实例挂同一个 NAS/NFS 卷data/目录指向共享路径。最简单但 NAS 本身是单点网络延迟对 JSONL 频繁 append 的影响需要实测。方案二对象存储。上传文件和工具结果迁到 MinIO 或 S3。适合大文件但对 JSONL 的逐行追加语义支持不好需要额外封装。方案三分布式文件系统。GlusterFS 或 Ceph多节点冗余、自动分片。运维负担重以当前的业务体量杀鸡用牛刀。方案四应用层会话亲和。不做文件系统层面的集群化而是让同一个 sessionId 的请求始终路由到同一个实例。Sticky session 把问题从文件系统怎么共享变成路由怎么亲和零存储改造。代价是实例宕机时该会话暂时不可用但恢复后文件在本地磁盘上不会丢。当前业务体量下这个方案最务实。当前是单实例不需要集群化文件系统。但四个方向在架构设计时都考虑过确保将来不会因为数据放文件系统所以改不了卡住。已存储的关系型数据当前 SQLite 有 9 张表统一用aac_前缀命名表名用途aac_users用户账号用户名、密码哈希、角色、启用状态、个人配置aac_projects项目归属用户、名称。一个用户可以有多个项目aac_sessions会话归属项目、名称。消息内容在 JSONL 文件里这里只存会话元信息aac_files上传文件元信息原始文件名、存储名、类型、大小、预览数据。实际文件在磁盘aac_skill_registry技能注册表技能名、展示名、描述、来源、scope、启用状态aac_user_disabled_global_skills用户技能禁用记录哪个用户禁用了哪个全局技能aac_llm_configsLLM 配置API 地址、加密 token、模型列表、当前激活的模型aac_mcp_serversMCP 服务器配置名称、连接配置 JSON、工具缓存、启用状态aac_sys_config系统配置全局开关如 LLM 日志模式、工具展示模式、缓存策略前四张是核心业务链路用户→项目→会话→文件。后面五张是配置和能力扩展分别对应技能系统、MCP 集成、LLM 接入和系统级开关。表之间的关系aac_users (1) ────── (N) aac_projects (1) ────── (N) aac_sessions │ │ │ └── (N) JSONL 消息文件 │ (data/messages/{sessionId}.jsonl) │ └── (N) aac_files │ └── (N) 磁盘文件 (data/projects/{projectId}/files/) aac_skill_registry (N) ──── (N) aac_user_disabled_global_skills │ │ └── (N) 磁盘文件 └── 用户级禁用开关 (data/skills/{name}/SKILL.md) aac_sys_config ─── 独立配置项无外键关联 aac_llm_configs ── 独立配置项运行时按 is_active 选择 aac_mcp_servers ── 独立配置项启动时初始化连接核心链路是两条业务链和能力链。业务链用户 → 项目 → 会话 → 文件的设计思路用户登录后首先面对的是项目列表。一个项目对应一个业务场景——比如2025年度审计、供应商对账。项目是文件的上传边界和会话的组织容器上传 Excel 对账单到项目 A在项目 B 的会话里是看不到这些文件的。会话挂在项目下一个项目可以有多个会话——可能是一次分析任务的完整对话也可能拆成数据清洗分析写报告三个独立会话取决于用户的习惯。消息 JSONL 跟着会话走删除会话时对应的消息文件一并清理。