惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

K
Kaspersky official blog
Engineering at Meta
Engineering at Meta
D
DataBreaches.Net
Stack Overflow Blog
Stack Overflow Blog
Microsoft Security Blog
Microsoft Security Blog
Y
Y Combinator Blog
B
Blog RSS Feed
GbyAI
GbyAI
P
Proofpoint News Feed
aimingoo的专栏
aimingoo的专栏
MyScale Blog
MyScale Blog
D
Docker
阮一峰的网络日志
阮一峰的网络日志
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Recorded Future
Recorded Future
美团技术团队
The Register - Security
The Register - Security
V
Visual Studio Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
Tailwind CSS Blog
爱范儿
爱范儿
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
T
The Blog of Author Tim Ferriss
博客园 - 司徒正美
量子位
B
Blog
F
Fortinet All Blogs
Martin Fowler
Martin Fowler
博客园 - 【当耐特】
MongoDB | Blog
MongoDB | Blog
A
About on SuperTechFans
I
InfoQ
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
有赞技术团队
有赞技术团队
雷峰网
雷峰网
大猫的无限游戏
大猫的无限游戏
J
Java Code Geeks
L
LangChain Blog
Latest news
Latest news
S
SegmentFault 最新的问题
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Blog — PlanetScale
Blog — PlanetScale
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Cisco Talos Blog
Cisco Talos Blog
F
Full Disclosure
C
Cisco Blogs
D
Darknet – Hacking Tools, Hacker News & Cyber Security
W
WeLiveSecurity
T
Tenable Blog
T
Tor Project blog

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 最小二乘问题详解20:无先验约束下的增量式SFM自由网平差 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解 【002】HTTPS 粗解:证书、TLS 握手与对后端配置的影响 Hermes Agent 一周暴涨五万 Star,但我劝你别急着追 明明连接的是Redis的DB0,为什么能查到DB3的数据? 【从0到1构建一个ClaudeAgent】协作-Agent团队 熟悉电子元器件之后,电子小白下一步该怎么走? MAF快速入门(23)通过C#类定义Skills .NET 高级开发 | 手写一个对象映射框架 FastAPI数据库ORM怎么选?我肝了三个Demo后,终于不再纠结了 mysqldump 参数拾遗:在遗忘与铭记之间 C# .NET 周刊|2026年3月5期 Claude code入门 - 陈彦斌 一文学习入门 ThingsBoard 开源物联网平台 GitHub 热门项目 | 2026年04月16日 如何为GIT设置全局勾子,为每次提交追加信息 Number.isFinite和isFinite与isNaN()和Number.isNaN的区别 PortSwigger SQL注入LAB2 推荐一个测试人必备的Skills,从功能到性能全搞定(附详细实操和安装下载方式) 筑基期:掌握Odoo基础核心知识点02(Odoo XML 开发方式详解) GLM模型这么火,咱们用vllm也咧一个呗! 深入理解 AbortController:从底层原理到跨语言设计哲学 字符串学习笔记 多租户系统框架的基础模块设计和分析设计 Apache SeaTunnel Zeta 为什么能做到“又快又稳”? AI开发-python-LangGraph框架(3-26-LangGraph基本概念及第一个简单样例) Vue 3 组件通信,别只会用 Props 和 Emits 了,这几个狠活儿你得看看 ElasticSearch7.X版本配置密码 用Manim实现动态交点计算--从一个动点问题说起 团结引擎+Addressable+Instant Game打包抖音小游戏 function call 实战:让 LLM 自动判断 pod 异常、调用日志工具并完成故障分析 bubseek —— 让 Agent 的足迹,变成团队的洞察 通过 C# 读取并导出 PDF 书签 如何用 GitHub Actions 实现 Steam 自动化发布 【从0到1构建一个ClaudeAgent】并发-后台任务 .NET 高级开发 | 定制 ASP.NET Core 框架 电子小白:什么是运算放大器(运放) zero2Agent:面向大厂面试的 Agent 工程教程,从概念到生产的完整学习路线 堆上的ORW HC32F460 USB CDC通信异常:非对齐访问异常排查 20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过 那些喊着AI 要淘汰你的人,正在靠你的焦虑赚大钱! 深度学习进阶(八)Swin Transformer 最小二乘问题详解19:带先验约束的增量式SFM优化与实现 SnapTranslate 3.0 正式发布:全局划词翻译 + 完整英语学习闭环,一站式搞定查词、记词、复习 工作的意义、工作的困难认知再思考 .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 开了 TUN 模式还是直连?90% 的人都踩过这个坑 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术
Cloud Agent 开发笔记(3):Web 交互与数据持久化
五岳 · 2026-06-04 · via 博客园_首页

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_uploadfinal_output 直接拒绝写入。从存储视角看,这意味着 Agent 可以在项目目录里自由创建、修改文件,但用户手动上传的原始数据和用户确认的最终结果被锁住了,不会因为 LLM 的一次误操作被覆盖。

工具执行的大结果走另一条路:超过 100KB 的不回传给 LLM,持久化到 data/projects/{projectId}/tool-results/{toolUseId}.txt,LLM 只收到路径加前 2000 字节的预览。单个响应内所有工具结果的总字符数有 20 万上限,防止一轮对话里几个大文件同时返回把上下文窗口撑爆。

Chat 路由里的持久化逻辑很保守:先写盘,再推 SSE。SSE 推失败(客户端断了),消息已经落盘了。不会出现"看到一半刷新页面,中间几条消息丢了"的情况。


下一篇聊能力扩展层:Skill 系统和 MCP 集成在 Web 多租户下的设计。