Cloud Agent 开发笔记(3):Web 交互与数据持久化
上一篇搭好了 Agent 引擎和 Tool 体系:query() 能跑、Tool 能调、安全有 pathGuard 兜底。但这一切都是在服务端发生的。Agent 生产的事件怎么到浏览器?消息怎么存才能让用户刷新页面不丢?多个会话同时跑怎么互不干扰?用户点了停止怎么办?
这四个问题串起了 CLI 到 Web 的最后一段路:Agent 的结果要送到浏览器,浏览器的用户要能随时回来接着聊。
事件怎么到浏览器:SSE 还是 WebSocket
Claude Code 的输出端是终端渲染器(Ink + React),事件通过 AsyncGenerator 直接传给 React 组件,没有网络传输这一步。V2 需要一套从服务端到浏览器的流式协议。
两个选项:WebSocket 还是 SSE?WebSocket 双向通信,能力更强。但 V2 的交互模式本质是单向的:服务端推送流式结果,客户端只在初始请求和点"停止"时发信号。SSE 更轻,原生支持断线重连,Hono 内置支持,不需要额外库。因此选了 SSE。
对于前端接收方式,浏览器有 EventSource API,原生处理 SSE,但它不支持自定义 headers,加不了 JWT token。所以用 fetch + ReadableStream 手动解析 SSE 字节流。多写了点代码,但认证链路不需要妥协。
线缆格式很直接:
event: text
data: {"content": "..."}
event: tool_result
data: {"tool_use_id":"xxx","content":"...","is_error":false}
定义了 10 种事件类型覆盖对话全部状态:
| 事件 | 触发时机 | 负载 |
|---|---|---|
text |
LLM 输出文本 token | { content: string } |
content_block_start |
LLM 开始输出 tool_use | { tool_use_id, tool_name } |
content_block_delta |
tool_use 参数流式增量 | { tool_use_id, input } |
content_block_stop |
tool_use 参数接收完毕 | { tool_use_id } |
tool_result |
Tool 执行完成 | { tool_use_id, content, is_error } |
usage |
每轮 LLM 调用结束 | { input_tokens, output_tokens, cache_* } |
done |
Agent 循环正常结束 | {} |
error |
循环异常 | { message } |
user |
中断插入的消息 | { content } |
user_question |
AskUserQuestion 触发 | { 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_result
synthetic 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 | 用户/项目/会话/配置元数据 | CRUD,WAL 模式 | SQL 筛选/排序/JOIN | 迁 PostgreSQL/MySQL |
| 磁盘文件 | 上传文件、大工具结果 | 一次写入,按 projectId 分目录 | 路径直读 | 迁 MinIO/S3 |
JSONL 存消息内容
消息的写入模式很单一:流式追加。LLM 每吐一个字、每个 tool_result 产生,都是 append 到末尾。没有修改、没有删除、没有复杂查询。JSONL 天然匹配这个模式:每行一个 JSON 对象,文件路径 data/messages/{sessionId}.jsonl,append 就完事,不需要事务、建索引、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_configs |
LLM 配置:API 地址、加密 token、模型列表、当前激活的模型 |
aac_mcp_servers |
MCP 服务器配置:名称、连接配置 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 跟着会话走,删除会话时对应的消息文件一并清理。
-
文件属于项目而非会话,这是一个刻意设计。同一个 Excel 对账单可能在多个会话里被引用------先在一号会话里清理数据,再在二号会话里做分析。如果文件跟着会话走,换个会话就要重新上传。文件元信息存
aac_files表,实际文件在data/projects/{projectId}/files/,这样不管从哪个会话引用,路径都是稳定的。
能力链是另一条独立的配置链路:技能注册表 ↔ 用户禁用开关,MCP 服务器配置、LLM 接入配置、系统配置各自独立。
对于aac_skill_registry和aac_user_disabled_global_skills的实际作用,在下一篇文章中会再做分析。
项目目录下的文件管理:上传、生成和保护
除了 JSONL,还有两类数据直接落磁盘:用户上传的文件、Agent 生成的输出。都放在 data/projects/{projectId}/files/ 下同一个目录,但通过数据库aac_files表的 source 字段区分身份:
user_upload:用户上传的原始文件(Excel、PDF、Word)。Agent 可以读,不能改也不能删。temp:Agent 在执行过程中生成的中间文件和输出。Agent 可以自由读写。final_output:用户手动标记的输出文件。和上传文件一样,Agent 不能动。
用数据库字段而不是目录结构来区分,是因为一个文件的身份可能变------Agent 生成的报告初稿是 temp,用户审查完标记为定稿就变成 final_output。如果是按目录隔离(上传放 /uploads/,生成放 /output/),改身份就得移动文件、更新所有引用路径。改一个字段比搬运文件省事得多。
上传文件的保护在 02 的 pathGuard 里已经提过:checkFileWritable 检查 source,遇到 user_upload 或 final_output 直接拒绝写入。从存储视角看,这意味着 Agent 可以在项目目录里自由创建、修改文件,但用户手动上传的原始数据和用户确认的最终结果被锁住了,不会因为 LLM 的一次误操作被覆盖。
工具执行的大结果走另一条路:超过 100KB 的不回传给 LLM,持久化到 data/projects/{projectId}/tool-results/{toolUseId}.txt,LLM 只收到路径加前 2000 字节的预览。单个响应内所有工具结果的总字符数有 20 万上限,防止一轮对话里几个大文件同时返回把上下文窗口撑爆。
Chat 路由里的持久化逻辑很保守:先写盘,再推 SSE。SSE 推失败(客户端断了),消息已经落盘了。不会出现"看到一半刷新页面,中间几条消息丢了"的情况。
下一篇聊能力扩展层:Skill 系统和 MCP 集成在 Web 多租户下的设计。