chore: import upstream snapshot with attribution
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:26 +08:00
commit 1443d3fdf9
732 changed files with 196602 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
# 智能体评估
Yuxi 的智能体评估用于回答一个具体问题:某个 Agent 在一组固定任务上能不能稳定完成工作。它不在 Yuxi 内部维护评估数据集、评分规则或对比报表,而是把这些能力交给 Langfuse;Yuxi 只负责按真实 Agent 运行链路执行每条样例,并把结果回写到 Langfuse experiment。
## 适用边界
这个功能面向 Agent 端到端行为评估,不是知识库检索指标评估。如果你要评估 RAG 检索召回、答案准确率和知识库基准,请使用「知识库评估」。如果你要评估一个 Agent 在编程、研究、工具调用、规划或多步骤任务上的真实表现,则使用本页介绍的 Langfuse dataset experiment 流程。
评估链路保持三个边界:
- Langfuse 负责 dataset、experiment、score、对比和可视化。
- Yuxi 后端负责创建正常 conversation 和 AgentRun,并复用 worker 执行链路。
- `yuxi` CLI 只负责读取 Langfuse dataset、运行 experiment、调用 Yuxi eval API,不负责创建或上传 dataset。
## 前置条件
1. Yuxi 后端已经启用 Langfuse tracing,并在 `.env` 中配置:
```bash
LANGFUSE_PUBLIC_KEY=...
LANGFUSE_SECRET_KEY=...
LANGFUSE_BASE_URL=https://cloud.langfuse.com
```
2. 本机 CLI 环境也能读取同一组 Langfuse 环境变量。`yuxi agent eval` 需要直接调用 Langfuse SDK 读取 dataset 和创建 experiment。
3. 已经登录 Yuxi CLI
```bash
yuxi remote add local http://localhost:5173
yuxi login --browser
```
评估命令必须使用当前 remote 的登录态,不支持在 `yuxi agent eval` 上直接传 token。CI 环境也必须先执行登录步骤,例如:
```bash
yuxi login --api-key "$YUXI_API_KEY"
```
4. 要评估的 Agent 已经存在,并且当前 CLI 登录用户有权限访问该 Agent。命令使用的是 Agent slug,例如 `default-chatbot`
## 准备 Langfuse Dataset
评估数据集必须先在 Langfuse 中准备好。CLI 不提供上传能力,避免把数据集管理职责混进运行命令。
Dataset item 的 `input` 推荐使用下面任一字段承载任务文本:
```json
{"input": "请用 Python 完成任务并给出最终答案:..."}
```
也兼容 `query``question``prompt``expected_output` 可以写标准答案,后续在 Langfuse UI 或 evaluator 中使用。
## 运行评估
上传 dataset 后,用 dataset name 运行:
```bash
yuxi agent eval \
--dataset-name yuxi-python-tasks-20260619-demo \
--agent-slug default-chatbot \
--experiment-name default-chatbot-python-tasks-20260619 \
--max-concurrency 1 \
--timeout-seconds 900
```
命令执行流程:
1. 从 Langfuse 读取 dataset。
2. 对每条 dataset item 提取任务文本。
3. 调用 `POST /api/agent-invocation/eval/runs`
4. Yuxi 后端创建正常 conversation 和 AgentRun。
5. worker 按真实 Agent 链路执行任务。
6. 接口阻塞到 run 终态后返回最终 assistant output。
7. CLI 将 output 写回 Langfuse experiment item。
`--max-concurrency` 控制 Langfuse experiment runner 的并发数。复杂 Agent 或本地开发环境建议从 `1` 开始,避免同时压垮模型服务、worker 或沙盒。
## 查看结果
评估完成后,在 Langfuse 控制台打开对应 dataset,可以看到刚创建的 experiment run。每条 item 会保存本次 Yuxi Agent 的最终输出。Yuxi 后端会在运行内部使用 `agent_invocation_meta.evaluation` 保存评估上下文,并给 Langfuse trace 写入 `agent_evaluation` 标记,方便筛选:
- `source=agent_evaluation`
- `evaluation_dataset_name=<dataset name>`
- `evaluation_dataset_item_id=<item id>`
- `evaluation_experiment_name=<experiment name>`
如果没有看到 experiment,先确认 CLI 环境中的 Langfuse key 和 dataset name 是否正确。如果 experiment 有记录但 Yuxi trace 缺失,检查 `api-dev` 容器是否读取到了同一组 Langfuse 配置。
+336
View File
@@ -0,0 +1,336 @@
# 智能体配置
Yuxi 的智能体系统基于 LangGraph 构建。对开发者来说,最重要的不是单独理解某个页面或某个字段,而是理解三件事:
- Agent 如何被定义和发现
- Context 如何驱动配置界面
- Context 如何贯穿一次 Agent 运行周期
本文聚焦这三部分。
## 1. 整体结构
智能体开发围绕四个核心对象展开:
- **`BaseAgent`**:统一的 Agent 抽象,定义 `get_graph()``context_schema``capabilities`
- **`BaseContext`**:配置 Schema,也是前端配置项的来源
- **Graph / Middleware**LangGraph 图与中间件链,决定运行时行为
- **Agent**:数据库中的一级智能体实例,保存展示信息、后端 `backend_id`、共享权限和 `config_json.context`
仓库中已经内置了可直接参考的智能体:
- `chatbot`:通用对话智能体,使用 `ChatBotContext` 扩展可调用子智能体配置
- `subagent`:专用子智能体后端,使用 `SubAgentContext`,用于被主 Agent 通过 task 工具调用
## 2. Agent 的代码组织
建议在 `backend/package/yuxi/agents` 下按包组织一个智能体:
```text
backend/package/yuxi/agents/
└── my_agent/
├── __init__.py
├── context.py
└── graph.py
```
最小实现通常包含:
- 一个继承 `BaseAgent` 的主类
- 一个 `context_schema`
- 一个 `get_graph()` 实现
示例:
```python
from yuxi.agents import BaseAgent, BaseContext, load_chat_model
from langchain.agents import create_agent
class MyAgent(BaseAgent):
name = "我的智能体"
description = "示例智能体"
context_schema = BaseContext
async def get_graph(self, context=None, **kwargs):
context = context or self.context_schema()
graph = create_agent(
model=load_chat_model(context.model),
system_prompt=context.system_prompt,
checkpointer=await self._get_checkpointer(),
)
return graph
```
## 3. Context 是配置模型,不只是运行时参数
### 3.1 `BaseContext` 的角色
`BaseContext` 定义在 `backend/package/yuxi/agents/context.py`,它不是一个普通的数据类,而是整个智能体配置链路的核心:
- 它定义了 Agent 可以配置哪些字段
- 它定义了这些字段在前端如何展示
- 它也是运行期传入 Graph 和中间件的上下文对象
当前基础字段包括:
| 字段 | 作用 |
| --- | --- |
| `system_prompt` | 系统提示词 |
| `model` | 主模型 |
| `tools` | 启用的内置工具 |
| `knowledges` | 关联知识库 |
| `mcps` | 启用的 MCP 服务器 |
| `skills` | 关联 Skills |
| `summary_threshold` | 摘要触发阈值 |
| `summary_prompt` | 摘要触发时使用的提示词 |
| `summary_keep_messages` | 摘要后保留的最近消息数 |
| `summary_tool_result_token_limit` | 工具结果 offload 阈值和预览 token 上限 |
| `summary_l2_trigger_ratio` | L1 后进入 L2 summary 的触发比例 |
| `max_execution_steps` | 单次运行最大执行步数 |
| `thread_id` / `uid` | 运行期标识,不作为页面配置项暴露 |
`tools``knowledges``mcps``skills` 在未显式配置时会默认启用当前用户可访问的全部资源。
`ChatBotContext``BaseContext` 之上增加 `subagents` 字段,表示当前主 Agent 允许调用的子智能体。`subagents` 未显式配置或保存空列表时会默认启用当前用户可见的全部子智能体;显式选择后则作为允许列表过滤。
`SubAgentContext``BaseContext` 之上增加 `parent_thread_id``file_thread_id``skills_thread_id``is_subagent_runtime` 等隐藏运行态字段,不包含 `subagents`,因此子智能体不能继续配置下一层子智能体。
### 3.2 前端配置项如何从 Context 生成
`BaseContext.get_configurable_items()` 会遍历字段定义,把字段类型、默认值、描述、模板元数据整理成 `configurable_items`
随后:
1. `BaseAgent.get_info()` 暴露 `configurable_items`
2. 前端读取 Agent 详情
3. `AgentRuntimeConfigForm``kind` 渲染不同控件
也就是说,`AgentRuntimeConfigForm` 不是手写每个字段,而是直接消费 `context_schema` 生成的配置描述。
这也是为什么:
- 新增一个 Context 字段,往往会直接影响侧边栏
- 字段的 `metadata` 信息会直接影响展示方式
### 3.3 配置表单与 Agent 的联动关系
这部分是最关键的。
在前端:
- `AgentRuntimeConfigForm.vue` 负责渲染配置表单
- `agentStore` 加载配置时,读取 `config_json.context`
- 如果某些字段未配置,会用 `configurable_items` 中的默认值补全
- 保存时,前端将当前表单写回 `config_json: { context: agentConfig }`
因此真实关系是:
```text
context_schema
-> get_configurable_items()
-> Agent detail API 返回 configurable_items
-> AgentRuntimeConfigForm 渲染表单
-> 用户编辑后保存到 config_json.context
```
这里需要特别注意两点:
- **侧边栏展示结构来自 `context_schema`**
- **配置实例值来自数据库中的 `config_json.context`**
前者决定“能配什么、怎么展示”,后者决定“当前配置实际选了什么”。
### 3.4 自定义 Context 的推荐方式
如果某个智能体有额外配置,不要在前端单独加一套表单,而是直接扩展 Context:
```python
from dataclasses import dataclass, field
from yuxi.agents import BaseContext
@dataclass(kw_only=True)
class MyAgentContext(BaseContext):
custom_mode: str = field(
default="default",
metadata={
"name": "运行模式",
"description": "控制智能体的自定义行为",
"options": ["default", "strict"],
},
)
```
然后在 Agent 中声明:
```python
class MyAgent(BaseAgent):
context_schema = MyAgentContext
```
这会同时影响:
- 后端可接收的配置结构
- 前端配置侧边栏的展示内容
- 运行期 `context` 可访问的字段
## 4. Context 如何贯穿 Agent 的运行周期
Context 的价值不只在“配置页面”。它贯穿了从配置加载到实际执行的整条链路。
### 4.1 配置加载阶段
在聊天请求进入后端时,服务会先解析请求中的 `agent_id` 或线程已绑定的 Agent,再加载对应配置。
当前主流程在 `chat_service.py` 中:
1. 新线程通过 `agent_id` 查找用户可访问的 Agent
2. 已有线程通过 `thread_id` 读取 `Conversation.agent_id`,并拒绝运行中切换 Agent
3. 取出 Agent 的 `config_json.context`
4.`uid``thread_id` 合并成运行时输入
也就是说,运行期 Context 的基础来源并不是前端临时状态,而是数据库中保存的 Agent。
此外,用户工作区会默认创建 `agents/AGENTS.md`。当 Agent 开始执行时,后端会读取当前用户工作区下的这个文件,并将其内容追加到 `system_prompt`,用于补充该用户对 Agent 的长期指令或工作区约定。该文件属于用户级共享工作区,内容会随 `uid` 和当前运行的线程作用域映射到运行时工作区路径;文件不存在、为空或不可读时不会影响 Agent 启动,单次注入内容最多读取 64 KiB,超出部分会截断并追加提示。
合并后的提示词结构可以理解为:
```text
Agent.config_json.context.system_prompt
+ 用户工作区 agents/AGENTS.md 内容
+ 运行期中间件继续追加的系统提示段
```
因此,`agents/AGENTS.md` 适合放置用户维度的稳定约束,不适合放置一次性任务要求;一次性要求仍应直接写在当前对话中。
### 4.2 Context 实例化阶段
`BaseAgent` 在运行前会创建 `context_schema()` 实例,并通过 `update_from_dict()` 注入配置值。
这一步完成后,Context 才真正成为运行期对象。
可以把它理解为:
```text
config_json.context + runtime ids -> context_schema instance
```
### 4.3 Graph 构建阶段
`get_graph(context=context)` 会收到这份 Context。
以内置 `chatbot` 为例,Context 会直接参与:
- 主模型选择:`context.model`
- 系统提示词拼接:`context.system_prompt`
- 可调用子智能体列表:`context.subagents`
- 摘要阈值:`context.summary_threshold`
因此 Graph 不是和 Context 解耦的。相反,Graph 的构造本身就依赖 Context。普通 Agent 在归一化后的 `context.subagents` 非空时会挂载 Yuxi 的 task middleware`SubAgentBackend` 自身隐藏并清空 `subagents` 字段,因此子智能体不会继续调用子智能体。
### 4.4 Graph 构建与中间件运行阶段
`get_graph()` 创建 LangGraph 前会先调用 `prepare_agent_runtime_context`,用当前用户重新过滤资源字段,并派生运行时字段:
- `_visible_knowledge_bases`:当前会话实际可查询的知识库对象
- `_prompt_skills`:需要注入提示词的 Skill 闭包
- `_readable_skills``/home/gem/skills` 和沙盒可读的 Skill 闭包
随后 Graph 构建会直接使用这份 Context:
- `load_chat_model(context.model)` 选择主模型
- `build_prompt_with_context(context)` 生成系统提示词
- `resolve_configured_runtime_tools(context)` 组装已配置的内置工具和 MCP 工具
- `KnowledgeBaseMiddleware` 根据 `_visible_knowledge_bases` 暴露知识库工具
- `SkillsMiddleware` 根据 `_prompt_skills` 注入 Skill 提示段,并在 Skill 被激活后按需挂载工具与 MCP 依赖
- `save_attachments_to_fs` 将线程附件转换为运行时可读的文件提示
文件系统与沙盒接入同样读取这些运行时字段:
- 普通 Agent 默认使用当前 `thread_id` 作为文件与 Skills 作用域
- 子智能体使用 child `thread_id` 做 checkpoint`file_thread_id` 指向父会话 uploads/outputs`skills_thread_id` 指向子智能体自身 Skills 作用域
- 通过 `_readable_skills` 决定 `/home/gem/skills` 的可读范围
所以 Context 既是输入配置,也是 Graph 创建前整理出的运行时资源上下文。
### 4.5 文件系统与 Viewer 阶段
文件系统服务不会重新发明一套配置结构,而是再次从 `config_json.context` 还原出 runtime context,用于:
- 判断当前线程下 Agent 可见的 Skills
- 构造 Agent 视图的 composite backend
- 构造 Viewer 视图的文件系统展示
这也是为什么 Context 不只是聊天链路的一部分,它还影响:
- Agent 文件工具
- Viewer 文件浏览器
- Skills 可见性
- 沙盒挂载语义
### 4.6 恢复运行阶段
`resume` 流程中,系统同样会通过线程绑定的 Agent 重新构造 Context,再继续执行 Graph。
也就是说,无论是:
- 首次对话
- 中断恢复
- 文件系统查看
它们都依赖同一份 Context 配置来源。
## 5. `capabilities` 的作用
`capabilities` 用于声明前端可直接从 Agent 静态元数据判断的能力开关,控制上传入口、文件面板等固定 UI,不等同于 Context,也不适合表达运行中才会出现的状态。
示例:
```python
class MyAgent(BaseAgent):
capabilities = ["file_upload", "files"]
```
当前常见能力包括:
| capability | 说明 |
| --- | --- |
| `file_upload` | 启用上传入口 |
| `files` | 启用文件面板 |
像 todo 这类运行态信息,不建议再放进 `capabilities`。Yuxi 当前会直接从 LangGraph state 中提取 `agent_state`,前端在创建对话后常态化展示状态入口,并在状态面板中渲染 `todos``files``artifacts``subagent_runs` 等运行时内容。
它解决的是“Agent 先天支持什么固定入口”,而不是“运行时当前产生了什么状态”。
## 6. 开发建议
### 6.1 新增配置时优先改 Context
如果一个配置项会影响 Agent 行为,优先考虑把它做成 `context_schema` 字段,而不是前端单独维护状态。
### 6.2 把 Graph 逻辑和配置逻辑分开
推荐做法:
- `context.py` 定义配置模型
- `graph.py` 使用这些配置构建 Graph
这样前后端联动关系会清晰很多。
### 6.3 把“配置来源”和“运行时状态”区分开
建议始终区分两层语义:
- `config_json.context`:持久化配置来源
- `runtime.context`:实际运行对象,可能被中间件继续补充或修改
## 7. 相关主题
- [工具系统](./tools-system.md)
- [中间件](./middleware.md)
- [沙盒架构与设计](./sandbox-architecture.md)
- [MCP 集成](./mcp-integration.md)
- [Skills 管理](./skills-management.md)
- [子智能体](./subagents-management.md)
- [Langfuse 集成](../advanced/langfuse-integration.md)
+51
View File
@@ -0,0 +1,51 @@
# MCP 集成
MCPModel Context Protocol)是扩展智能体能力的重要方式。系统支持通过管理界面动态配置 MCP 服务器,无需修改代码。
内置 MCP 服务器以代码为事实源:系统启动时会自动补齐缺失项,并用代码中的最新连接与展示字段覆盖数据库定义;是否“已添加”以及工具级禁用列表仍保留数据库状态。
## 支持的传输协议
| 协议 | 说明 | 适用场景 |
|------|------|----------|
| Streamable HTTP | 流式 HTTP 连接 | 远程 MCP 服务 |
| SSE | Server-Sent Events | 标准 HTTP 长连接 |
| Stdio | 标准输入输出 | 本地进程 |
## 配置示例
### 远程 MCP 服务
```json
{
"name": "custom-remote-mcp",
"transport": "streamable_http",
"url": "https://example.com/mcp"
}
```
### 本地 Python 进程
```json
{
"name": "mysql-mcp-server",
"transport": "stdio",
"command": "uvx",
"args": ["mysql_mcp_server"],
"env": {
"MYSQL_HOST": "localhost",
"MYSQL_DATABASE": "your_database"
}
}
```
## 服务器管理
管理界面使用“添加 / 移除”语义管理 MCP 服务器:
- 已添加:`enabled=true`,会加载到运行时缓存并可供 Agent 使用
- 可添加:`enabled=false`,记录保留但不会进入运行时
## 工具管理
MCP 工具支持粒度控制:管理员可以单独启用或禁用某个 MCP 服务器下的特定工具,实现精细化的权限管理。
+97
View File
@@ -0,0 +1,97 @@
# 中间件系统
中间件是 Yuxi 扩展智能体运行行为的主要机制。它工作在 LangGraph Agent 的模型调用、工具调用、状态更新和文件系统访问路径上,用来把知识库、Skills、附件、子智能体、上下文压缩和运行观测接入同一条执行链路。
内置 `ChatbotAgent``SubAgentBackend` 都会在 `get_graph()` 中构建中间件列表。运行前的资源过滤不再依赖旧版运行时配置中间件,而是在创建 Graph 前由 `prepare_agent_runtime_context` 完成。
## 运行时准备
运行时准备不是中间件,但它决定后续中间件能看到什么资源。内置 Agent 创建 Graph 前会先执行以下步骤:
- `prepare_agent_runtime_context`:按当前用户权限过滤工具、知识库、MCP、Skills 和子智能体,并派生 `_visible_knowledge_bases``_prompt_skills``_readable_skills`
- `build_prompt_with_context`:基于 Context 生成系统提示词
- `load_chat_model(context.model)`:加载主模型
- `resolve_configured_runtime_tools(context)`:加载已配置的内置工具和 MCP 工具
这意味着中间件不负责重新判断“用户是否能访问某个资源”。它们消费的是已经归一化后的 runtime context。
## 内置中间件链路
当前内置 `ChatbotAgent` 的中间件顺序如下:
| 中间件 | 作用 |
| --- | --- |
| `create_agent_filesystem_middleware` | 接入沙盒文件系统、用户工作区、线程 uploads/outputs 与只读 Skills 路由,并在工具结果过大时把内容写入 `outputs/large_tool_results` |
| `save_attachments_to_fs` / `AttachmentMiddleware` | 从 LangGraph state 的 `uploads` 读取附件路径,把可读路径注入系统提示,提示模型按需使用 `read_file` |
| `SkillsMiddleware` | 注入可见 Skill 的提示段,监听读取 `SKILL.md` 后的 Skill 激活,并按依赖追加工具和 MCP 工具;知识库工具由内置 `knowledge-base` Skill 按需加载 |
| `YuxiSubAgentMiddleware` | 仅主 Agent 在存在可见子智能体时挂载,提供 `task` 工具调用真实子 Agent graph |
| `YuxiSummarizationMiddleware` | 基于 DeepAgents `SummarizationMiddleware` 做长上下文压缩,并清洗被摘要历史里的工具结果 |
| `TodoListMiddleware` | 提供待办状态,让前端状态面板可展示 Agent 运行进度 |
| `PatchToolCallsMiddleware` | 修正部分工具调用消息形态,提升工具调用兼容性 |
| `ModelRetryMiddleware` | 在模型调用失败时按配置重试 |
| `TokenUsageMiddleware` | 在 LangGraph state 写入本轮 token 使用快照,供前端状态面板查看 |
`SubAgentBackend` 使用同一组核心能力,但不会挂载 `YuxiSubAgentMiddleware`,并额外过滤 `present_artifacts``ask_user_question``install_skill` 等不适合子智能体直接使用的工具。
## 知识库工具
知识库访问能力沉淀为内置 `knowledge-base` Skill。Agent 读取 `/home/gem/skills/knowledge-base/SKILL.md` 激活该 Skill 后,`SkillsMiddleware` 会按依赖追加 `list_kbs``query_kb``find_kb_document``open_kb_document``get_mindmap` 等知识库工具。
实际可见知识库仍由 `prepare_agent_runtime_context` 根据当前用户和 Agent 配置写入 `_visible_knowledge_bases`,工具执行时只会在这批知识库中检索。`context.knowledges` 是资源范围,不是 Skill 本身。
系统不会把知识库文件树挂进沙盒。Agent 访问知识库内容应使用 `query_kb``find_kb_document``open_kb_document`,而不是遍历 `/home/gem/kbs` 这类旧路径。
## Skills 注入与激活
`SkillsMiddleware` 分两步工作:
1. 模型调用前读取 `_prompt_skills`,把可见 Skill 的名称、描述和 `SKILL.md` 路径追加到系统提示。
2. 工具调用后检查模型是否读取了 `/home/gem/skills/<slug>/SKILL.md`。如果该 Skill 在 `_readable_skills` 范围内,就把它写入 `activated_skills`,并在后续模型调用中追加它声明的工具和 MCP 依赖。
这种设计让 Skill 可以先作为说明可见,只有模型真正读取并激活后才扩展工具集,避免一开始就把所有依赖工具塞进上下文。
## 附件与文件系统
附件上传后会先落盘到线程文件系统,并在 LangGraph state 中记录 `uploads``AttachmentMiddleware` 只把文件名和可读路径注入提示词,不会把文件内容整体塞进模型上下文。模型需要查看附件时,应通过 `read_file` 读取对应路径。
文件系统中间件负责把 sandbox backend、线程 uploads/outputs、用户工作区和只读 Skills 组合成 Agent 可访问的虚拟文件系统。普通 Agent 默认使用当前 `thread_id` 作为文件作用域;子智能体使用 child `thread_id` 做 checkpoint,同时沿用父线程的 uploads/outputs,并使用子 Agent 自己的 Skills 作用域。
## 子智能体任务
主 Agent 如果配置了可见子智能体,会挂载 `YuxiSubAgentMiddleware` 并获得 `task` 工具。这个工具不会调用旧版独立 SubAgents 表,而是查找 `agents.is_subagent=true` 且后端为 `SubAgentBackend` 的真实 Agent 配置,然后启动对应子 Agent graph。
子智能体执行时会获得独立 child thread、独立 checkpoint 和 `agent_runs(run_type=subagent)` 记录;工具结果会返回 child thread ID,后续可以把该 ID 传回 `task` 继续同一个子任务。子智能体自身不会再挂载下一层 `task` 中间件,避免形成嵌套子智能体链路。
## Summary 上下文压缩
长对话压缩由 Yuxi 封装的 `YuxiSummarizationMiddleware` 负责。它基于 DeepAgents 的 `SummarizationMiddleware`,但针对 Yuxi 的知识库检索和工具调用结果做了额外处理。
触发条件来自 Agent Context
| 字段 | 说明 |
| --- | --- |
| `summary_threshold` | 上下文超过该 K token 阈值后触发摘要;L2 摘要模型的待摘要历史输入上限也使用同一阈值 |
| `summary_keep_messages` | 摘要后保留最近消息数 |
| `summary_prompt` | 摘要模型使用的提示词 |
| `summary_tool_result_token_limit` | 工具结果 offload 阈值和预览 token 上限 |
| `summary_l2_trigger_ratio` | L1 后进入 L2 summary 的触发比例,建议 `0.1~1.0`,默认 `0.4` |
触发判断使用 Yuxi 自己的近似 token 计算结果,不使用模型返回的 `usage_metadata.total_tokens` 作为触发依据,避免 provider 的计费口径、累计口径或异常上报导致短对话过早压缩。
触发后,中间件先执行 L1 结构精简:在本次模型调用的临时消息视图里截断旧 `write_file`/`edit_file` 工具调用的大参数;`ToolMessage.content` 估算 token 数超过 `summary_tool_result_token_limit` 时,会写入当前 Agent 可见的 `outputs/large_tool_results`,消息内替换为工具名、近似 token 数、完整结果路径和不超过同一 token 上限的预览。未超过该上限的工具结果保持原样。这个步骤不修改 LangGraph state 中的原始消息。
L1 后会重新计算上下文大小;如果仍超过入口阈值乘以 `summary_l2_trigger_ratio`,才进入 L2 summary,把较早的 L1 视图消息压缩成一条 summary message,并保留最近窗口内的原始消息。比例越小越容易进入 L2;`1.0` 表示 L1 后仍超过原始触发阈值才进入 L2。L2 传给摘要模型的待摘要历史上限等于 `summary_threshold` 对应的 token 数,避免用过小的固定窗口丢掉早期关键信息。L2 不再对工具结果做第二轮 offload,只写入 `_summarization_event`,后续调用仍由 DeepAgents 的 cutoff 语义重建 effective messages。
这对知识库检索尤其重要:`query_kb``open_kb_document``find_kb_document` 等工具可能返回较长的片段、引用和文档内容。Summary 阶段保留“查过什么、结果在哪里、关键预览是什么”,同时避免把大量检索原文反复卷入摘要,减少上下文污染和 token 压力。
未达到入口阈值的常规模型调用不会额外清洗工具结果;达到入口阈值但 L1 后低于 L2 门槛时,会直接用 L1 精简后的临时视图调用模型,不生成 summary event。
## 自定义中间件
新增中间件时,将实现放入 `backend/package/yuxi/agents/middlewares`,再在具体 Agent 的 `get_graph()` 中加入 `middleware` 列表。新增前先确认它属于哪一种职责:
- 资源过滤、权限收敛和默认资源选择应放在 `prepare_agent_runtime_context` 一类的 Graph 创建前逻辑中。
- 模型提示注入、工具动态追加、工具结果处理和 state 更新适合做成 LangChain Agent middleware。
- 文件读写、工具结果卸载和 artifacts 展示应优先复用 `create_agent_filesystem_middleware` 与沙盒 backend。
仓库中仍保留 `DynamicToolMiddleware`,但当前内置 Agent 的工具和 MCP 加载已经由 `resolve_configured_runtime_tools(context)``SkillsMiddleware` 承担。新增功能时不要默认复用旧的动态工具中间件,除非确实需要“预注册后按请求筛选”的模式。
+306
View File
@@ -0,0 +1,306 @@
# Yuxi 沙盒架构说明
::: tip info
本文档是由 Codex 联合撰写,开发者审阅,尽管已经多次校对,但仍可能存在不准确或过时的描述。如果你发现任何问题,欢迎提交 issue 或 PR 来帮助我们改进文档。
:::
我们在 Yuxi 里引入沙盒,不是为了让架构更“重”,而是因为 Agent 一旦从纯文本对话进入真实执行阶段,就一定会碰到一组很具体的运行时需求:执行命令、读写文件、处理用户上传附件、产出可下载结果,以及在受控目录里保留中间过程文件。如果把这些能力直接放进 API 进程本身,权限边界、租户隔离、环境一致性和后续运维成本都会迅速恶化。
从设计目标上看,沙盒这一层主要解决三件事。第一,给 Agent 一个可写、可执行、可回收的独立运行空间,而不是让它直接操作应用主进程。第二,把模型可见文件系统整理成稳定的命名空间,例如 `/home/gem/user-data``/home/gem/skills`,这样 prompt、工具、viewer 和 artifact 下载接口可以共享同一套路径语义。第三,让这套能力既能在本地 Docker 开发环境里稳定工作,也能在需要时切到 Kubernetes 这类更适合多实例部署的承载方式。
这份文档说明当前项目中“沙盒”这一层到底是什么、为什么同时会看到 Docker 和 Kubernetes、默认开发环境实际启用的是哪一种模式,以及沙盒如何和 `skills`、附件、工作区文件系统组合在一起工作。内容以当前仓库实现为准,我们重点解释真实调用链、配置入口、路径语义和运维边界,而不是抽象地介绍容器技术。
## 一、先说明白:Docker 和 K8s 在这里是什么关系
Docker 和 Kubernetes 不是互斥关系。Docker 解决的是“把一个进程放进容器里运行”这个问题,Kubernetes 解决的是“如何在一组机器上批量调度、暴露、重建和管理这些容器”这个问题。可以把 Docker 理解成容器运行时和镜像分发方式,把 Kubernetes 理解成容器编排平台。
放到 Yuxi 里,这个关系更具体一些。Yuxi 本身并不直接决定“沙盒一定跑在 Docker 还是一定跑在 K8s 上”,它只要求后端拿到一个可访问的沙盒地址,然后通过 `agent-sandbox` 的 HTTP API 去执行命令、读写文件。真正负责创建和回收沙盒实例的是 `sandbox-provisioner` 这个单独的服务。也就是说,Yuxi 的应用层只依赖 “provisioner”,而 provisioner 的后端可以选择用本机 Docker 去起容器,也可以选择向 Kubernetes 集群创建 Pod 和 Service。
所以项目里看到的概念其实分成两层。第一层是应用层的 `SANDBOX_PROVIDER`,当前代码只支持 `provisioner`。第二层是 provisioner 内部的 `SANDBOX_PROVISIONER_BACKEND`,它决定具体用哪种底层实现去创建沙盒。当前真正应该对外理解和配置的是 `docker``kubernetes`,测试或占位场景可以使用 `memory`
## 二、当前项目的真实沙盒调用链
当前仓库里,后端只支持 `SANDBOX_PROVIDER=provisioner`。当某个对话线程第一次需要执行文件操作或命令执行时,后端会基于文件线程与 skills 线程生成稳定的 `sandbox_id`,然后请求 `sandbox-provisioner` 创建或复用对应沙盒;普通 Agent 的文件线程和 skills 线程都回退为当前 `thread_id`。应用层拿到返回的 `sandbox_url` 之后,才会真正通过 `agent-sandbox` 客户端去调用远程沙盒的文件 API 和 shell API。
调用链可以概括为:Web/API 请求进入 Yuxi 后端,后端构造 `ProvisionerSandboxBackend`,再经由 `ProvisionerClient` 调用 `sandbox-provisioner``/api/sandboxes` 接口。`sandbox-provisioner` 根据 `SANDBOX_PROVISIONER_BACKEND` 选择内存占位实现、Docker 容器实现或 Kubernetes 实现。沙盒真正启动后,对外暴露一个 HTTP 地址,Yuxi 再使用这个地址完成执行命令、上传文件、下载文件、目录遍历等操作。
当前仓库的默认配置和默认开发环境都应该理解为 `docker`。正常情况下运行中的 provisioner 健康检查应返回 `backend=docker`。这意味着我们用 `docker compose up -d` 启动项目时,应用并不是直接把代码跑在宿主机上,而是通过 `sandbox-provisioner` 再去用 Docker 启一个真正的沙盒容器。
## 三、`memory`、`docker`、`kubernetes` 分别是什么
当前实现里,`memory``docker``kubernetes` 是三种需要区分的语义。
`memory` 是一个纯内存登记实现。它不会真正创建容器,也不会提供真实隔离,主要适合测试或极轻量的占位场景。它只是记录一个 `sandbox_id -> sandbox_url` 的映射,因此不能把它理解成生产可用的沙盒。
`docker` 是当前默认也是推荐的本机容器后端。`sandbox-provisioner` 会使用 `LocalContainerProvisionerBackend` 通过宿主机 Docker daemon 动态创建沙盒容器。
`kubernetes` 则是另一条实现路径。它不会再去调用本机 Docker 起容器,而是使用 Kubernetes API 在指定 namespace 中创建一个 Pod 和一个 NodePort Service,然后把这个 Service 对应的可访问地址回传给 Yuxi 后端。
因此,如果在界面、文档或者环境变量里看到 “docker / k8s” 这几个词,最准确的理解应该是:Yuxi 的应用层只有一种 provider,也就是 `provisioner`provisioner 下面有多种 backend;其中 `docker` 是默认的本机 Docker 后端,`kubernetes` 是另一种远程集群后端。
## 四、默认开发模式到底是什么
默认开发模式是 Docker Compose 启动整个项目,再由 `sandbox-provisioner``docker` 后端去创建沙盒容器。也就是说,项目本身跑在 Compose 里,沙盒也跑在 Docker 里,只不过沙盒不是 Compose 静态声明的长期服务,而是 provisioner 按需动态拉起和回收的短生命周期容器。
这也是为什么在 `docker-compose.yml` 中既能看到 `api``worker``sandbox-provisioner` 这样的常驻服务,又能看到 `sandbox-provisioner` 挂载了 `/var/run/docker.sock`。这不是重复设计,而是为了让 provisioner 有能力继续调用宿主机 Docker daemon 去创建新的“每线程沙盒容器”。
换句话说,当前项目不存在单独的 “纯宿主机 local 模式”。本机开发和单机部署应显式使用 `docker` 后端。
这里还需要把 Compose 里的环境变量分两层看。`api``worker` 关注的是应用层变量,例如 `SANDBOX_PROVIDER``SANDBOX_PROVISIONER_URL``SANDBOX_VIRTUAL_PATH_PREFIX``SANDBOX_EXEC_TIMEOUT_SECONDS``SANDBOX_MAX_OUTPUT_BYTES``sandbox-provisioner` 自己则有另一组变量,负责决定具体如何创建沙盒实例。两层不要混看,否则很容易误以为改了 API 环境变量就能切换底层承载方式。
## 五、Docker 本机后端是如何工作的
`SANDBOX_PROVISIONER_BACKEND=docker` 时,`sandbox-provisioner` 会进入 `LocalContainerProvisionerBackend`。它会检查 Docker 是否可用,解析自身容器里 `/app/saves` 这个挂载点在宿主机上的真实路径,并据此推导出线程数据目录。随后它为每组文件线程与 skills 线程准备一个稳定的 `sandbox_id`,把容器命名为类似 `yuxi-sandbox-<id>` 的形式,并在 Docker 网络中启动真正的沙盒镜像。
这个沙盒镜像默认来自 `SANDBOX_IMAGE`,容器内部监听的端口默认是 `8080`。provisioner 在启动容器时,会把这个端口随机映射到宿主机上的一个可用端口,再用 `DOCKER_SANDBOX_HOST` 拼出形如 `http://host.docker.internal:<random_port>` 的访问地址。Yuxi 后端拿到的就是这个地址。
Docker 后端在启动沙盒时,会挂载三类关键目录。第一类是用户级 workspace,挂载到容器内的 `/home/gem/user-data/workspace`。第二类是文件线程级 uploads/outputs,分别挂载到 `/home/gem/user-data/uploads``/home/gem/user-data/outputs`。第三类是 skills 线程可见的 skills 目录,挂载到 `/home/gem/skills`,而且是只读挂载。除此之外,容器的 `/home/gem` 本身还会额外挂一个 `tmpfs`,原因是当前沙盒镜像启动时要求 `/home/gem` 可写,但 Yuxi 希望真正持久化的只有 `user-data` 下面的内容。
为了避免长期空闲的沙盒一直占资源,provisioner 还带了一个 idle reaper。它会记录每个沙盒最近一次被 touch 的时间,超过 `SANDBOX_IDLE_TIMEOUT_SECONDS` 之后自动删除。当前默认空闲超时是 120 秒,但如果这个值小于命令执行超时,系统会自动把它提高到“命令超时 + 30 秒”,以免执行中的任务被误回收。
对应到 `docker-compose.yml``docker-compose.prod.yml`,当前 `sandbox-provisioner` 实际会读取的 Docker 后端相关变量主要是这些:
- 通用变量:`PROVISIONER_BACKEND``SANDBOX_IMAGE``SANDBOX_CONTAINER_PORT``SANDBOX_HEALTH_TIMEOUT_SECONDS``SANDBOX_IDLE_TIMEOUT_SECONDS``SANDBOX_IDLE_CHECK_INTERVAL_SECONDS``SANDBOX_EXEC_TIMEOUT_SECONDS``MEMORY_SANDBOX_URL_TEMPLATE`
- Docker 后端变量:`DOCKER_NETWORK``DOCKER_THREADS_HOST_PATH``DOCKER_SANDBOX_PREFIX``DOCKER_SANDBOX_HOST`
- 容器代理变量:`HTTP_PROXY``HTTPS_PROXY``NO_PROXY`
其中 `DOCKER_SANDBOX_HOST` 只在 Docker 后端下用于拼接返回给 API 的 `sandbox_url``DOCKER_THREADS_HOST_PATH` 也是 Docker 后端专用;如果不显式传入,provisioner 会尝试根据自身容器挂载反推出宿主机路径。
## 六、Kubernetes 后端是如何工作的
`SANDBOX_PROVISIONER_BACKEND=kubernetes` 时,`sandbox-provisioner` 会改用 Kubernetes Python 客户端。它会先加载 kubeconfig 或集群内配置,然后在指定的 namespace 中创建一个沙盒 Pod,再创建一个同名的 NodePort Service,把这个 Service 的 `nodePort` 暴露给 Yuxi 后端使用。
Kubernetes 后端下,沙盒还是同一套镜像,还是暴露同样的 HTTP API,但存储方式和暴露方式变了。它不会依赖宿主机 Docker bind mount,而是要求有一个可写的 PVC。当前实现里真正使用的是 `THREAD_PVC`Pod 会把这块共享存储挂到 `/mnt/shared-data`,然后用 `subPath` 的方式把 `threads/shared/<uid>/workspace` 挂到 `/home/gem/user-data/workspace`,把 `threads/<file_thread_id>/user-data/uploads``threads/<file_thread_id>/user-data/outputs` 分别挂到 uploads/outputs,把 `threads/<skills_thread_id>/skills` 挂到 `/home/gem/skills`。这样做的好处是目录结构仍然可以和 Docker 模式保持一致,同时允许子智能体共享父对话文件但隔离 skills。
需要特别说明的是,代码里虽然读取了 `SKILLS_PVC` 这个环境变量,但当前 Pod 规格实际没有使用单独的 skills PVC,而是统一从 `THREAD_PVC` 中切 `threads/<thread_id>/skills` 这个子路径。因此,如果看到环境变量里同时出现 `SKILLS_PVC``THREAD_PVC`,应当以 `THREAD_PVC` 的真实挂载语义为准,`SKILLS_PVC` 目前更像一个预留字段。
Kubernetes 后端还需要一个 `NODE_HOST`。这是因为当前实现使用的是 NodePort Service,而不是 Ingress,也不是 ClusterIP。provisioner 创建完 Service 之后,会把最终访问地址拼成 `http://<NODE_HOST>:<nodePort>` 返回给 Yuxi 后端。所以 `NODE_HOST` 必须是 Yuxi 后端能够访问到的 Kubernetes 节点地址、负载均衡地址或者对 NodePort 做了透出的外部域名。
当前 Compose 中与 Kubernetes 后端对应的变量主要是:
- `K8S_NAMESPACE`
- `KUBECONFIG_PATH`
- `NODE_HOST`
- `THREAD_PVC`
- `SKILLS_PVC`
其中真正决定运行时挂载的是 `THREAD_PVC``SKILLS_PVC` 目前只保留为代码层读取字段,并没有进入实际 Pod 挂载。
## 七、如果要使用“远程 K8s”,应该怎么接
这里最容易误解的一点是,所谓“选择远程 K8s”,并不是在 Yuxi 页面里点一个开关,然后系统自动发现一个集群。当前实现没有内建集群选择器,也没有多集群管理界面。它的工作方式很直接:我们把 `sandbox-provisioner` 配置成 `kubernetes` 后端,并让它能拿到目标集群的 kubeconfig 或者运行在集群内即可。对 provisioner 来说,只要 Kubernetes 客户端能连上 API Server,这个集群就是它要操作的“远程 K8s”。
如果 Yuxi 部署在 Docker Compose 里,而 Kubernetes 集群在另一台机器或云厂商托管环境中,那么最常见的做法是把本地 kubeconfig 文件挂载进 `sandbox-provisioner` 容器,然后设置 `KUBECONFIG_PATH`。同时把 `SANDBOX_NODE_HOST` 改成一个从 `api` 容器也能访问的节点公网 IP、负载均衡域名,或者已经做过反向代理的地址。
一个典型的 Compose 覆盖配置会长这样:
```yaml
services:
sandbox-provisioner:
environment:
- PROVISIONER_BACKEND=kubernetes
- K8S_NAMESPACE=yuxi-know
- KUBECONFIG_PATH=/root/.kube/config
- THREAD_PVC=yuxi-thread
- SKILLS_PVC=yuxi-skills
- NODE_HOST=203.0.113.10
volumes:
- ~/.kube/config:/root/.kube/config:ro
```
这段配置表达的意思不是“把整个应用迁到 K8s”,而是“仍然用 Compose 跑 Yuxi 主服务,但沙盒实例改为由远程 Kubernetes 集群承载”。这是当前代码最自然的混合部署方式。
如果 `sandbox-provisioner` 本身就运行在 Kubernetes 集群内部,那么通常不需要显式提供 `KUBECONFIG_PATH`。它会优先尝试 `incluster_config`,也就是使用 Pod 的服务账号权限直接访问 Kubernetes API。此时更需要关注的是 namespace、PVC 和 NodePort 的可达性,而不是 kubeconfig 文件本身。
## 八、当前项目的沙盒文件系统是如何设计的
从模型和工具调用的视角看,Yuxi 主要向 Agent 暴露两类路径:`/home/gem/user-data``/home/gem/skills`。其中 `user-data` 是可写的用户工作区,`skills` 是只读的技能目录。知识库不再映射为沙盒文件系统路径,模型应通过知识库工具检索和打开文档。
在宿主机侧,和线程相关的数据主要放在 `saves` 目录下。当前可读的目录结构可以概括为下面这样:
```text
saves/
├── skills/
│ ├── <skill-slug>/
│ └── ...
├── threads/
│ ├── <thread_id>/
│ │ ├── user-data/
│ │ │ ├── uploads/
│ │ │ ├── outputs/
│ │ │ └── ...
│ │ └── skills/
│ │ ├── <skill-slug>/
│ │ └── ...
│ ├── shared/
│ │ └── <uid>/
│ │ └── workspace/
│ └── ...
```
这里要重点理解 `workspace``uploads/outputs` 的区别。按照当前宿主机路径解析逻辑,`workspace` 被定义为用户级共享目录,位置是 `saves/threads/shared/<uid>/workspace`;而 `uploads``outputs` 属于文件线程目录,位置分别是 `saves/threads/<file_thread_id>/user-data/uploads``saves/threads/<file_thread_id>/user-data/outputs`。普通 Agent 的 `file_thread_id` 就是当前对话 `thread_id`,子智能体运行时则使用父对话作为 `file_thread_id`,因此可以读取父对话附件并把产物写回父对话 outputs。
与此同时,运行时 provisioner 在创建 Docker 容器或 Kubernetes Pod 时,会把用户级 `saves/threads/shared/<uid>/workspace` 单独挂到 `/home/gem/user-data/workspace`,再把文件线程的 `uploads/outputs` 分别挂到 `/home/gem/user-data/uploads``/home/gem/user-data/outputs`。因此在排查文件问题时,需要先明确一个前提:当前项目里同时存在“宿主机侧目录组织”和“容器内统一虚拟路径”两层概念。对外接口和 viewer 语义与底层挂载实现现在是一致的,workspace 是用户共享空间,而 uploads/outputs 跟随文件线程隔离或共享。
## 九、路径暴露规则是什么
Yuxi 不会把整个容器文件系统都开放给 Agent 或 viewer。当前 viewer 根目录只会列出几个命名空间入口,而不会直接暴露 `/` 的真实文件树。这样做是为了避免只看文件树就触发沙盒冷启动,也为了让权限边界更稳定。
`/home/gem/user-data` 是主要工作区。它允许模型和工具写入,但推荐语义并不相同。内置 prompt 中已经明确说明,`workspace` 应当放中间文件,`outputs` 应当放最终产物,`uploads` 是用户上传文件的位置。对于普通对话 Agent,文案甚至提示“非必要不要写 workspace,而优先写 outputs”。
`/home/gem/skills` 是只读目录。它不是简单地把 `saves/skills` 整个暴露进去,而是先根据当前运行时的 `_readable_skills`,把这些技能从全局 skills 根目录同步复制到 `saves/threads/<skills_thread_id>/skills`,再把这个 skills 线程目录只读挂进沙盒。这样做的结果是,不同主/子 Agent 看到的 skill 集可能不同,而且模型永远不能在运行时修改 skills 内容。
知识库访问不属于沙盒文件系统暴露规则。当前 Agent 可见知识库仍由用户权限和 Agent 配置共同决定,但只通过 `query_kb``open_kb_document` 等工具访问,不提供沙盒目录投影。
## 十、skills、知识库、附件是怎么和沙盒结合的
skills 的结合方式分成两层。第一层是提示词层,`prepare_agent_runtime_context` 会先根据当前 Agent 配置的 `context.skills` 展开依赖闭包,`SkillsMiddleware` 再把 `_prompt_skills` 注入到系统提示里,让模型知道哪些 skill 存在、它们的入口文件一般在 `/home/gem/skills/<slug>/SKILL.md`。第二层是文件系统层,运行时会调用 `sync_thread_readable_skills`,把 `_readable_skills` 对应的 skill 目录复制到当前 `skills_thread_id``saves/threads/<skills_thread_id>/skills` 下,再由沙盒只读挂载到 `/home/gem/skills`。也就是说,skill 既是 prompt 中的能力说明,也是文件系统中的只读知识目录。
附件的结合方式更偏向“先落盘,再把路径告诉模型”。用户上传文件后,系统会先把原始文件写入 `saves/threads/<file_thread_id>/user-data/uploads`。如果该文件可以被解析,系统还会额外生成一个 Markdown 副本,写到 `saves/threads/<file_thread_id>/user-data/uploads/attachments/<name>.md`。普通 Agent 的文件线程就是当前对话线程;子智能体沿用父对话文件线程,所以能访问父对话附件。随后,LangGraph state 中会维护一份 `uploads` 列表,`AttachmentMiddleware` 会把这些可读路径注入系统提示,告诉模型优先用 `read_file` 去读取这些路径。因此,附件并不是“作为消息大段内联塞给模型”,而是被转换成沙盒文件系统中的路径对象。
知识库不再与沙盒文件系统结合。它不会被复制到每个线程目录,也不会生成虚拟目录;模型通过专门的知识库工具检索,并在需要更完整上下文时用 `open_kb_document``resource_id``file_id` 打开文档内容。
## 十一、当前推荐如何使用 Docker 沙盒
如果只是正常开发、调试或单机部署,最简单也是当前默认的方式就是保留 `SANDBOX_PROVIDER=provisioner`,同时把 `SANDBOX_PROVISIONER_BACKEND` 设为 `docker`。这会让整个项目继续由 Docker Compose 管理,而沙盒实例由 provisioner 动态创建。通常不需要手工 `docker run` 沙盒镜像,也不需要在 Compose 文件里静态声明每一个沙盒容器。
最小必要配置通常就是下面这几项:
```env
SANDBOX_PROVIDER=provisioner
SANDBOX_PROVISIONER_URL=http://sandbox-provisioner:8002
SANDBOX_PROVISIONER_BACKEND=docker
SANDBOX_VIRTUAL_PATH_PREFIX=/home/gem/user-data
SANDBOX_DOCKER_SANDBOX_HOST=host.docker.internal
```
然后用常规方式启动即可:
```bash
docker compose up -d
curl http://localhost:8002/health
```
如果健康检查返回 `backend: docker`,就说明 provisioner 已经处于默认的 Docker 本机后端。真正的沙盒容器不会在系统启动时立即全部出现,而是在你第一次创建线程并触发需要文件系统或命令执行的操作后才会被创建。
如果运行在 Linux,而不是 Docker Desktop,那么 `host.docker.internal` 不一定总是可用。这时要把 `SANDBOX_DOCKER_SANDBOX_HOST` 改成一个从 `api` 容器可达的宿主机地址,或者改成当前网络环境里更稳定的名字。否则 provisioner 虽然能成功起容器,但后端可能拿到一个自己无法访问的 `sandbox_url`
## 十二、如何理解文件管理与暴露边界
从产品行为上看,viewer 文件系统和 artifact 下载接口优先走的是宿主机路径解析,而不是无条件透传到沙盒容器内部。这么设计有两个直接收益。第一,浏览 `/``/home/gem/user-data` 这样的树形入口时,不需要为了只读查看而冷启动沙盒。第二,权限边界更好做,因为 `resolve_virtual_path` 会把用户可见路径严格限制在预定义的 `user-data``skills` 命名空间内。
从工程上看,当前实现更像“双层文件系统”。对 Agent 执行来说,真正工作的对象是远程沙盒进程暴露的文件 API;对 viewer、附件下载和一部分 artifact 查看来说,系统会优先在宿主机侧解析虚拟路径,再用本地文件读取或只读 backend 下载内容。这也是为什么你会看到既有 `ProvisionerSandboxBackend`,又有 `viewer_filesystem_service``SelectedSkillsReadonlyBackend` 这样的配套实现。
## 十三、环境变量配置与传递链
sandbox-provisioner 的环境变量传递分**两层**,需要分别理解:
### 第一层:应用层 → sandbox-provisioner
`api``worker` 服务通过 `SANDBOX_*` 前缀的环境变量告诉后端如何连接 provisioner。这些变量定义在 `docker-compose.yml``x-api-worker-env` 锚点中:
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `SANDBOX_PROVIDER` | 提供者类型,固定为 `provisioner` | `provisioner` |
| `SANDBOX_PROVISIONER_URL` | provisioner 服务地址 | `http://sandbox-provisioner:8002` |
| `SANDBOX_VIRTUAL_PATH_PREFIX` | 虚拟路径前缀 | `/home/gem/user-data` |
| `SANDBOX_EXEC_TIMEOUT_SECONDS` | 命令执行超时时间 | `180` |
| `SANDBOX_MAX_OUTPUT_BYTES` | 最大输出字节数 | `262144` |
### 第二层:sandbox-provisioner 内部配置
`sandbox-provisioner` 服务本身读取另一组环境变量,决定如何创建沙盒容器。这些变量直接写在 `docker-compose.yml``sandbox-provisioner.environment` 中:
**通用配置:**
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `PROVISIONER_BACKEND` | 底层后端类型,`docker``kubernetes` | `docker` |
| `SANDBOX_IMAGE` | 沙盒容器镜像 | 详见 compose 文件 |
| `SANDBOX_CONTAINER_PORT` | 沙盒容器内部端口 | `8080` |
| `SANDBOX_IDLE_TIMEOUT_SECONDS` | 空闲回收时间 | `120` |
| `SANDBOX_HEALTH_TIMEOUT_SECONDS` | 健康检查超时 | `300` |
**Docker 后端专用:**
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `DOCKER_NETWORK` | Docker 网络名称 | `yuxi-know_app-network` |
| `DOCKER_SANDBOX_PREFIX` | 沙盒容器名前缀 | `yuxi-sandbox` |
| `DOCKER_SANDBOX_HOST` | 宿主机访问地址 | `host.docker.internal` |
| `DOCKER_THREADS_HOST_PATH` | 线程数据宿主机路径 | 自动推断 |
**Kubernetes 后端专用:**
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| `K8S_NAMESPACE` | Kubernetes namespace | `yuxi-know` |
| `NODE_HOST` | Kubernetes 节点地址 | `host.docker.internal` |
| `KUBECONFIG_PATH` | kubeconfig 文件路径 | 空(使用 incluster 配置) |
| `THREAD_PVC` | 线程数据持久化卷 | `yuxi-thread` |
| `SKILLS_PVC` | 技能目录持久化卷(预留) | `yuxi-skills` |
### 环境变量传递链
```
宿主机 .env / 系统环境变量
docker-compose.yml
┌────────────────────────────────┐
│ api/worker 服务 │ 应用层变量 (SANDBOX_*)
│ SANDBOX_PROVISIONER_URL │
└────────────┬───────────────────┘
↓ HTTP 调用
┌────────────────────────────────┐
│ sandbox-provisioner 服务 │ 沙盒层变量 (PROVISIONER_BACKEND, DOCKER_*, K8S_*)
│ PROVISIONER_BACKEND │
└────────────┬───────────────────┘
↓ Docker API / K8s API
┌────────────────────────────────┐
│ 动态创建的沙盒容器 │
└────────────────────────────────┘
```
两层变量不要混看。改了 `api/worker``SANDBOX_PROVISIONER_URL` 只是改了后端找 provisioner 的地址;改了 `sandbox-provisioner``PROVISIONER_BACKEND` 才是改了 provisioner 本身用什么方式创建沙盒。
### sandbox.env 的特殊作用
`docker/sandbox_provisioner/sandbox.env` 文件的用途与上述两层变量不同。它通过 volume 挂载到 provisioner 容器内 (`/app/sandbox.env`),然后由 `LocalContainerProvisionerBackend` 在创建沙盒容器时读取,解析后的键值对会作为**环境变量注入到每个动态创建的沙盒容器**中。
```yaml
# docker-compose.yml 中 sandbox-provisioner 的挂载
sandbox-provisioner:
volumes:
- ./docker/sandbox_provisioner/sandbox.env:/app/sandbox.env:ro
```
也就是说,`sandbox.env` 配置的是沙盒容器内部可见的环境变量,而不是 provisioner 本身的配置。当前该文件内容为:
```env
CHECK_YUXI_SANDBOX_ENV_EXISTS=True
```
如果需要给所有沙盒容器注入额外的环境变量(如代理配置、认证信息等),可以添加到 `sandbox.env` 文件中。
### 配置方式汇总
| 配置目标 | 配置位置 | 示例变量 |
|----------|----------|----------|
| 应用层连接 provisioner | `.env` 或 compose 环境 | `SANDBOX_PROVISIONER_URL` |
| provisioner 自身行为 | `.env` 或 compose 环境 | `PROVISIONER_BACKEND`, `DOCKER_*` |
| 沙盒容器内部环境 | `sandbox.env` 文件 | 代理、认证等运行时变量 |
## 十四、和旧版文档相比,今天最重要的理解方式
当前项目不应再按“应用直接管理一个长期存在的本地 sandbox 服务”去理解。更准确的认识应该是:Yuxi 只管理线程和上下文;provisioner 负责创建线程对应的沙盒实例;文件系统不是简单地暴露一个容器根目录,而是把可写工作区、只读 skills 等组合成一个受控命名空间(知识库不再映射为沙盒目录,改由 `query_kb`/`open_kb_document` 等工具访问)。
因此,当你在界面上“启用沙盒”或者在文档里“选择 K8s”时,本质上做的不是切换一段业务逻辑,而是在切换 provisioner 的底层实例承载方式。选择 `docker` 时,沙盒由当前部署机上的 Docker daemon 动态创建;选择 `kubernetes` 时,沙盒由目标 K8s 集群动态创建。Yuxi 自己始终只面对一个 provisioner 服务地址。
## 十五、排障时建议先看什么
如果怀疑是 provisioner 级问题,先看 `http://localhost:8002/health`,确认 backend 类型和 idle timeout 是否符合预期。默认 Docker 部署下这里应看到 `backend=docker`。接着看 `docker logs sandbox-provisioner --tail 200`,因为这里能直接看到创建容器、复用旧实例、健康检查失败和 idle reaper 删除的日志。
如果怀疑是 Docker 地址不可达,重点检查 `SANDBOX_DOCKER_SANDBOX_HOST` 和随机映射端口是否从 `api` 容器可访问。可以在 `api` 容器内直接 `curl` provisioner 返回的 `sandbox_url`。如果怀疑是 Kubernetes 地址不可达,重点检查 `NODE_HOST` 和 NodePort 的外部连通性,因为当前实现并不是通过集群内部 Service 名称回连。
如果怀疑是文件看得到但模型读不到,或者模型写了但 viewer 看不到,优先把问题拆成两层:一层是宿主机路径是否存在于 `saves/...` 下,另一层是该路径是否真的被当前线程沙盒挂载并暴露到了 `/home/gem/user-data``/home/gem/skills`。只要先分清“宿主机侧文件语义”和“沙盒侧运行时挂载语义”,定位问题通常会快很多。
+350
View File
@@ -0,0 +1,350 @@
# Skills 管理系统
Skills 是 Yuxi 系统中用于扩展 Agent 能力的重要机制。通过 Skills,开发者可以将特定的工具、提示词模板或领域知识打包成可复用的技能包,让 Agent 在对话过程中能够调用这些额外能力。
## 为什么需要 Skills
在实际业务场景中,我们常常会遇到一些特定的需求:比如需要 Agent 能够查询特定的 API、调用某个外部服务、或者使用特定的提示词模板来完成特定任务。传统的做法是在代码中硬编码这些功能,但这样会导致系统变得越来越臃肿,且难以复用。
Skills 系统的设计理念就是将这类"可插拔"的能力封装成独立的技能包。每个 Skill 包含完整的实现文件和元数据,Agent 可以根据配置动态加载所需的技能,实现能力的灵活组合。
## 架构设计
Skills 系统采用「文件系统存内容,数据库存索引」的分离架构:
```
┌─────────────────────────────────────────────────────────────┐
│ Skills 存储架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ /app/saves/skills/ 数据库索引 │
│ ├── skill-a/ ┌──────────────┐ │
│ │ ├── SKILL.md │ skills 表 │ │
│ │ ├── tools/ │ - slug │ │
│ │ └── prompts/ │ - name │ │
│ └── skill-b/ │ - description│ │
│ ├── SKILL.md │ - dir_path │ │
│ └── ... │ - source_type│ │
│ │ - share_config │
│ │ - enabled │ │
│ │ - deps... │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 存储结构
- **文件系统**`/app/saves/skills` 目录下,每个 Skill 占用一个子目录
- **数据库索引**`skills` 表存储元数据(slug、name、description、来源、共享范围、启用状态、依赖关系等)
- **关联机制**:通过 `dir_path` 字段关联文件系统目录与数据库记录
::: tip 不能直接在文件系统创建
由于 Skills 的元数据需要写入数据库,因此不能直接在文件系统中创建 Skill。必须通过系统的导入或安装功能来完成,系统会自动处理数据库记录的创建。
:::
## 创建方式
系统提供以下方式创建或安装 Skills:
1. **推荐 Skill 安装**:在 Skills 管理页的推荐分组点击 `+`,系统会拉取对应远程来源并生成安装草稿
2. **ZIP / SKILL.md 上传**:上传后先解析为安装草稿,确认共享范围后再写入正式 Skills 存储和数据库
3. **远程仓库安装**:填写 skills 仓库地址、ModelScope Skill 地址或合集地址,后端调用 `npx skills` 下载并解析为安装草稿,确认后导入系统
4. **在线编辑**:对已有且可管理的 Skill 在线创建目录、编辑文件和维护依赖
5. **Agent 内安装**:主智能体可通过 `install_skill` 工具,从沙盒路径或 Git 来源安装当前用户私有 Skill;子智能体禁用该工具
不建议直接操作数据库或文件系统导入 Skill。直接写文件不会自动生成 `skills` 表记录,也无法参与权限、依赖和沙盒挂载。
## Skills 来源
Skills 本质上是提示词和工具的封装,以下是一些可以参考的 Skills 实现:
- **Anthropic 官方 Tools**https://github.com/anthropics/skills 可以参考其 skills 的组织方式和提示词设计
- **ModelScope Skill 市场**https://modelscope.cn/skills 支持单个 Skill 地址,也支持合集地址批量拉取
- **MiniMax-AI CLI**https://github.com/MiniMax-AI/cli 文本、图片、视频、语音和音乐生成 + Web 搜索(可通过 `MiniMax-AI/cli` 远程安装)
- **社区 Skills**:各平台分享的 Agent 提示词模板
- **自定义开发**:根据业务需求自行开发
## 快速开始
### 创建你的第一个 Skill
一个标准的 Skill 目录结构如下:
```
my-awesome-skill/
├── SKILL.md # 必选,Skill 的核心定义文件
├── tools/ # 可选,相关的工具脚本
│ └── helper.py
└── prompts/ # 可选,提示词模板
└── system.md
```
其中 `SKILL.md` 是每个 Skill 必须包含的核心文件,它采用 Markdown + Frontmatter 格式:
```markdown
---
name: My Awesome Skill
slug: my-awesome-skill
description: 这是一个用于处理特定任务的技能
---
# Skill 使用说明
这里是技能的详细使用文档,Agent 会读取这部分内容来了解如何使用这个技能。
## 功能列表
1. 功能一:xxx
2. 功能二:yyy
## 使用示例
当用户 xxx 时,可以调用此技能...
```
**Frontmatter 字段说明:**
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | 是 | Skill 展示名称,可使用更易读的名称(如 `Word / DOCX` |
| `slug` | 否 | Skill 唯一标识,必须是小写字母、数字、短横线的组合,且不能连续短横线(如 `my-skill`)。未填写时兼容旧格式,系统会使用 `name` 作为 slug,此时 `name` 也必须满足 slug 规则 |
| `description` | 是 | Skill 的功能描述,会在 Agent 配置时展示 |
### 导入 Skill
可以通过以下方式导入或安装 Skill:
**方式一:从推荐列表安装**
1. 在系统设置的「Skills 管理」页面查看「推荐」分组
2. 未安装的推荐 Skill 会以普通 Skill 卡片样式展示,右侧显示 `+`
3. 点击推荐卡片或 `+` 后,系统会使用该 Skill 的远程来源拉取内容
4. 拉取成功后会弹出安装草稿,确认共享范围后完成安装
已安装的推荐 Skill 不会继续显示在「推荐」分组中。
**方式二:通过 ZIP 包或 SKILL.md 上传**
1. 将 Skill 目录打包成 ZIP 文件(注意:ZIP 的根目录就是 Skill 目录)
2. 在系统设置的「Skills 管理」页面,点击「上传 Skill」
3. 上传 ZIP 文件或单个 `SKILL.md`
4. 系统解析上传内容并返回安装草稿
5. 确认共享范围后完成安装;也可以放弃草稿
系统会自动:
- 校验 ZIP 内容和路径安全性
- 检查 slug 冲突(如有冲突会自动追加 `-v2` 等后缀)
- 解析 SKILL.md 的 frontmatter 并存储到数据库
- 按当前用户角色校验可选择的共享范围
**方式三:从远程来源安装**
1. 在 Skills 管理页面点击「远程安装」
2. 在“按仓库拉取”中填写来源,例如:
- `anthropics/skills`
- `https://github.com/anthropics/skills`
- `https://modelscope.cn/skills/@anthropics/pdf`
- `https://modelscope.cn/collections/MiniMax/MiniMax-Office-skills`
3. 点击“拉取技能”获取该来源中可发现的 Skills 列表
4. 单个 Skill 地址通常会自动选中;仓库或合集地址可在列表中勾选一个或多个 Skills
5. 点击“解析并确认”,系统返回安装草稿,确认共享范围后正式安装
也可以切换到“全局搜索发现”,输入关键字检索 skills.sh 上的开源 Skills,再选择结果安装。
系统会在后端:
- 调用 `npx skills add <source> --list` 校验来源并发现可安装的 skills
- 使用隔离的临时 `HOME` 执行 `npx skills add <source> --skill <name> -g -y --copy`
- 从临时目录中提取对应 skill,再按现有导入流程生成草稿;确认后写入 `/app/saves/skills` 与数据库
::: tip ModelScope 合集适合批量安装
ModelScope 合集地址可以作为远程来源填写,例如 `https://modelscope.cn/collections/MiniMax/MiniMax-Office-skills`。拉取后在列表中勾选需要的 Skills,再统一解析为安装草稿。
:::
**方式四:在线编辑已有 Skill**
在 Skills 管理页面,你可以:
- 新建目录或文件
- 在线编辑文本文件(支持 .md、.py、.js、.json 等格式)
- 直接在网页上修改 SKILL.md 内容
只有具备 `can_manage` 权限的用户才能编辑文件、依赖、共享范围和启用状态。
::: tip 远程安装不会把 ~/.agents/skills 作为系统主存储
远程安装只把 `skills.sh` CLI 作为“下载器”使用。Yuxi 仍然以 `/app/saves/skills + skills 表` 作为正式来源,这样才能与现有的权限、线程可见性和沙盒挂载机制保持一致。
:::
## 依赖系统
Skills 之间可以建立依赖关系,形成一个松耦合的技能网络。
### 依赖类型
每个 Skill 可以声明三类依赖:
| 依赖类型 | 说明 | 加载时机 |
|----------|------|----------|
| `tool_dependencies` | 需要的内置工具 | 激活后按需加载 |
| `mcp_dependencies` | 需要的 MCP 服务 | 激活后按需加载 |
| `skill_dependencies` | 依赖的其他 Skill | 会话启动即生效 |
### 渐进式加载机制
系统采用三级渐进式加载策略,确保资源的高效利用:
**阶段一:会话启动**
当 Agent 会话启动时,系统会:
1. 在创建 Graph 前读取已过滤的 `context.skills` 列表
2. 递归展开 `skill_dependencies`,派生 `_prompt_skills``_readable_skills`
3.`_prompt_skills` 对应的技能说明注入到系统提示词中
这意味着:只要配置了某个 Skill,它的依赖 Skill 就会立即进入提示词和沙盒 `/home/gem/skills` 只读范围。
**阶段二:技能激活**
当 Agent 通过 `read_file` 工具读取 `/home/gem/skills/<slug>/SKILL.md` 时,视为"激活"该技能。系统会:
1. 验证该技能在可见列表中
2. 将其添加到 `activated_skills` 列表
3. 后续的模型调用会使用激活列表来加载依赖
**阶段三:按需加载**
每次模型调用时,系统会:
1. 检查 `activated_skills` 中的技能
2. 收集这些技能的 `tool_dependencies``mcp_dependencies`
3. 动态将需要的工具和 MCP 服务添加到可用工具集中
这种设计的好处是:不会在会话开始时加载所有工具,而是根据 Agent 实际使用情况按需加载,既节省资源又保证响应速度。
### 依赖声明示例
假设我们有三个 Skills
- **base-skill**:基础技能,无依赖
- **advanced-skill**:依赖 `base-skill`
- **pro-skill**:依赖 `advanced-skill`
当在 Agent 配置中只选择 `pro-skill` 时:
1. 启动阶段:`_readable_skills` = [`pro-skill`, `advanced-skill`, `base-skill`](自动展开依赖链)
2. Agent 首次调用任何 skill 时:所有三个 Skill 都可读
3. 当 Agent 读取 `pro-skill/SKILL.md` 时:触发激活,工具和 MCP 依赖被加载
## 权限管理
Skills 使用 `source_type``share_config``enabled` 控制来源、共享范围和启用状态。
| 字段 | 说明 |
|------|------|
| `source_type` | `builtin``upload``remote` |
| `share_config.access_level` | `global``department``user` |
| `enabled` | 是否允许在 Agent 配置与运行时使用 |
访问与管理规则:
| 用户 | 可见 / 可用 | 可管理 |
|------|-------------|--------|
| 超级管理员 / 管理员 | 可查看可管理或已启用且可访问的 Skills | 可管理所有非内置 Skills;可启停内置 Skills |
| 普通用户 | 可查看已启用且对自己可访问的 Skills,也可安装自己的私有 Skill | 可管理自己创建的非内置 Skills |
| 内置 Skills | 默认全局共享并启用 | 管理员可启停;不允许删除或直接编辑文件 |
共享范围限制:
- `global`:所有用户可访问
- `department`:指定部门用户可访问
- `user`:指定用户可访问;普通用户安装时只能选择个人范围
管理员和普通用户在创建或编辑 Agent 时,都只能从自己可访问且启用的 Skills 中选择能力。
## 运行时行为
### Agent 如何使用 Skills
1. **提示词注入**:系统在每次模型请求时动态注入可用 Skills 的描述(请求级注入,避免污染 runtime context
2. **文件访问**Skills 目录以只读方式挂载到 `/home/gem/skills/<slug>/...`
3. **工具调用**:当 Agent 需要使用某个 Skill 时,会先读取对应的 SKILL.md 了解使用方法
### 文件操作限制
运行时 `/home/gem/skills` 路径有以下限制:
- **只读**:Agent 只能读取文件内容
- **禁止写入**:不能创建、修改或删除文件
- **路径安全**:所有路径都经过安全校验,防止目录穿越攻击
::: tip 虚拟文件系统限制
当前 Skills 目录挂载为虚拟文件系统,**不支持 shell 命令执行**。Skill 中的脚本仅作为提示词参考,Agent 无法直接执行这些脚本。如果需要执行特定功能,建议通过 MCP 工具或自定义工具实现。
:::
### 会话隔离
每个 Agent 会话都有独立的 Skills 可见集:
- 不同会话可以配置不同的 Skills
- 同一会话内修改 `context.skills` 会触发快照重建
- 后台修改 Skills 内容后,已有会话不会自动刷新
## 最佳实践
### Skill 命名规范
- `slug` 使用小写字母、数字和短横线,不能连续短横线
- `slug` 应具有描述性,如 `weather-query``sql-reporter`
- `name` 用于展示,可比 `slug` 更自然,例如 `Word / DOCX`
- 避免过长的 `name``slug`
### 依赖管理建议
- **保持依赖链简洁**:层级不宜过深,一般 1-2 层为宜
- **避免循环依赖**:系统会检测并阻止循环依赖
- **明确依赖必要性**:只在真正需要共享能力时才建立依赖
### SKILL.md 编写技巧
```markdown
---
name: example-skill
description: 简短描述技能功能
---
# 技能名称
这里是详细的使用说明...
## 何时使用
描述在什么场景下应该使用这个技能...
## 使用方法
1. 第一步...
2. 第二步...
## 示例
```
具体的使用示例...
```
```
## 常见问题
**Q:为什么我配置的 Skill 没有生效?**
A:请检查以下几点:
1. Skill 的 slug 是否正确配置在 Agent 的 `context.skills`
2. SKILL.md 是否存在且 frontmatter 格式正确
3. 如果使用了依赖,确保依赖链完整
**Q:如何更新已导入的 Skill**
A:可以通过以下方式:
1. 导出当前 Skill,修改后重新导入
2. 在 Skills 管理页面在线编辑文件
3. 远程来源的 Skill 可重新解析并确认安装,形成新的导入结果
**QSkill 依赖的工具/MCP 不存在怎么办?**
A:系统会在保存依赖配置时进行校验,如果引用的工具或 MCP 不存在,会报错并阻止保存。
---
通过 Skills 机制,Yuxi 为 Agent 提供了一个灵活、可扩展的能力扩展框架。你可以将自己积累的业务知识、工具能力封装成 Skills,让不同的 Agent 复用这些能力,极大地提升了系统的可维护性和复用性。
+140
View File
@@ -0,0 +1,140 @@
# 子智能体
Yuxi 的子智能体是 Agent-backed 形态:它仍然是 `agents` 表中的一级 Agent,只是额外带有 `is_subagent=true` 标记,并使用专用后端 `SubAgentBackend`。子智能体不再有独立的创建入口、独立表或独立管理接口。
## 用户视角
### 子智能体能解决什么问题
当任务复杂、需要分工处理时,主 Agent 可以通过 `task` 工具把一个子任务交给子智能体。例如:
- 通用型子任务:交给内置 `general-purpose` 子智能体,使用默认运行配置处理分析、整理、写作或文件处理。
- 研究型子任务:聚焦检索和资料整理。
- 评审型子任务:对草稿进行结构和质量审查。
- 领域型子任务:使用指定模型、工具、知识库或 Skills 处理特定领域问题。
### 在哪里创建和编辑
子智能体与普通 Agent 使用同一个管理入口:进入模型配置中的“智能体”管理页,点击新增智能体,并在后端类型中选择 `SubAgentBackend`
创建和编辑流程与普通 Agent 保持一致:
- 展示信息、共享权限、系统提示词和运行配置都保存在同一份 Agent 配置中。
- 模型、工具、知识库、MCP 和 Skills 仍通过 Agent runtime config 表单配置。
- 子智能体不会出现在聊天页的 Agent 快速切换列表中。
- 子智能体不能再配置或调用其他子智能体。
### 如何让主 Agent 调用子智能体
主 Agent 会通过 runtime config 的“子智能体”字段确定 `task` 工具可调用的子智能体范围。
`subagents` 字段表示当前主 Agent 的允许列表:
- 未选择或保存空列表时,默认启用当前用户可见的全部子智能体,包括内置 `general-purpose`
- 显式选择后,只允许调用所选子智能体。
- 只会调用当前用户可访问且 `is_subagent=true` 的 Agent。
- 每个子智能体使用自己的 `config_json.context`,包括模型、工具、知识库、MCP、Skills 和系统提示词。
内置 `general-purpose``config_json.context` 为空,运行时会按 `SubAgentContext``BaseContext` 默认值解析模型、工具、知识库、MCP 与 Skills。
## 开发者视角
### 数据模型
子智能体复用 `agents` 表,核心字段包括:
| 字段 | 说明 |
|------|------|
| `backend_id` | 子智能体固定使用 `SubAgentBackend` |
| `is_subagent` | 子智能体标记,`SubAgentBackend` 必须对应 `true` |
| `config_json.context` | 子智能体自己的运行配置 |
| `share_config` | 可见性与管理权限,沿用 Agent 共享模型 |
后端会校验 `backend_id``is_subagent` 一致:普通 Agent 不能伪装成子智能体,`SubAgentBackend` 也不能以普通 Agent 形态保存。子智能体不能被设置为默认 Agent。
### API 与列表语义
子智能体沿用 `/api/agent` CRUD
- `GET /api/agent` 默认只返回聊天可用的普通 Agent。
- `GET /api/agent?include_subagents=true` 返回管理页需要的完整 Agent 列表。
- 创建或更新 `SubAgentBackend` 时,payload 会携带或推导 `is_subagent=true`
- 详情、更新和删除仍走同一套 Agent 管理接口,并复用现有权限过滤。
旧的独立 SubAgent 管理链路已经移除,不再维护单独的启停状态、内置初始化或 spec 缓存。
### 运行时调用链
主 Agent 构图时,会先把 `context.subagents` 归一化为当前用户可见的允许列表;允许列表非空时挂载 Yuxi 的 task middleware。middleware 会把允许的子智能体列表注入模型提示,并暴露一个 `task` 工具。
工具参数为:
```python
class TaskToolSchema(BaseModel):
description: str
subagent_slug: str
thread_id: str | None = None
```
`thread_id` 是可选的子智能体线程 ID。新任务不需要填写;如果要继续之前同一个子智能体任务,应使用上一次 `task` 工具结果中的 `子智能体线程 ID`
执行时的关键流程:
1. 从父 Agent 的 `context.subagents` 读取允许的子智能体 slug;未显式配置或空列表会展开为当前用户可见的全部子智能体。
2. 使用 `AgentRepository` 加载当前用户可见且 `is_subagent=true` 的 Agent。
3. 新任务会为本次调用生成 child checkpoint thread id,例如 `<parent_thread_id>_sub_<slug>_<uuid8>`;续跑任务会校验并复用传入的 `thread_id`
4. 使用子智能体自己的 `SubAgentContext``config_json.context` 构建真实 Agent graph。
5. 调用结束后,把子智能体线程 ID 和最终 assistant 文本作为 `task` 工具结果返回给主 Agent。
`SubAgentBackend` 复用普通 Agent 的运行时资源归一化流程,但不会挂载 task middleware;它的 `subagents` 字段隐藏且默认为空,因此不会形成嵌套子智能体调用。
### 同步调用与异步调用
`task` 是同步工具:父智能体调用后会阻塞等待子智能体 run 走到终态,再拿到最终 assistant 文本。这种模式适合短任务,例如父智能体必须立即依赖子智能体结果继续推理时。
但当子任务耗时较长或可以并行多个时,同步等待会让父智能体长时间停在工具调用上,无法继续工作。因此 middleware 还同时暴露一组异步子智能体生命周期工具:
| 工具 | 作用 | 关键参数 |
|------|------|----------|
| `subagent_start` | 异步启动子智能体 run,立即返回 `run_id``thread_id` | `description``subagent_slug`、可选 `thread_id` |
| `subagent_status` | 按 `run_id` 查询状态,附带最近 3 条可读进度摘要;run 终态时返回最终结果 | `run_id` |
| `subagent_events` | 按运行 Redis 流游标读取增量事件 | `run_id`、可选 `after_seq`(默认 `0-0`)、`limit`1-50 |
| `subagent_cancel` | 取消运行中的子智能体 run | `run_id` |
| `subagent_await` | 阻塞等待子智能体 run 终态并返回最终结果;超时返回当前快照和 `wait_timed_out` 标志 | `run_id` |
调用约束:
- 长任务或多个可并行任务优先使用 `subagent_start`,让父智能体继续推进主流程;短任务需要立即拿到结果时继续使用 `task`
- `thread_id` 是子智能体的长期上下文 ID,同一个 `thread_id` 终态后可以再创建新的 run 续跑。若同线程已有运行中的 run,`subagent_start` 会返回 busy 结构,不会隐藏排队。
- `subagent_status``subagent_events``subagent_cancel``subagent_await` 都按 `run_id` 操作,并校验该 run 是否归属当前父 run 创建的子智能体,避免越权访问其它子任务。
- 父智能体不应通过 shell、curl 或 HTTP API 间接调用子智能体,所有调用必须走上述工具。
异步子智能体在状态面板的「子智能体」分组中按 `run_id` 展示运行身份;状态/事件轮询工具不会渲染成独立 Agent 卡片,弹窗会随子智能体条目补齐 `run_id` 后订阅对应 SSE,已完成的子智能体改为直接读取持久化 Message 历史。
### 文件系统与沙盒作用域
子智能体与主 Agent 共享文件系统时使用拆分作用域:
| 路径/作用域 | 普通 Agent | 子智能体 |
|------|------|------|
| LangGraph checkpoint | 当前 `thread_id` | child `thread_id` |
| `/home/gem/user-data/workspace` | 当前 `uid` 的共享工作区 | 同一 `uid` 的共享工作区 |
| `/home/gem/user-data/uploads` | 当前会话文件作用域 | 父会话 `file_thread_id` |
| `/home/gem/user-data/outputs` | 当前会话文件作用域 | 父会话 `file_thread_id` |
| `/home/gem/skills` | 当前 Agent 的 Skills 作用域 | 子智能体自己的 `skills_thread_id` |
这保证子智能体可以读取父会话上传、产物也会回到父会话 artifacts 中,同时子智能体的 Skills 不会污染主 Agent。
## 常见问题
### 为什么创建了子智能体,主 Agent 仍不会调用?
主 Agent 只会调用当前用户可访问的子智能体。如果主 Agent 显式保存了子智能体允许列表,新建子智能体需要被加入该列表;未显式配置或空列表会使用当前用户可见的全部子智能体。
### 为什么聊天 Agent 列表里看不到子智能体?
这是预期行为。子智能体是被主 Agent 调用的后端配置,不是直接进入聊天的 Agent;管理页会使用包含子智能体的列表。
### 子智能体能否继承主 Agent 的模型或工具?
子智能体运行时使用自己的 Agent 配置。确实需要一致时,应在子智能体配置中显式选择相同模型、工具或 Skills;运行时只继承必要的父会话作用域,例如 uploads/outputs。
+96
View File
@@ -0,0 +1,96 @@
# 工具系统
Yuxi 的工具系统基于注册机制,支持多种工具类型的动态组装。
## 工具注册机制
Yuxi 的工具系统采用 `@tool` 装饰器注册机制,核心位于 `backend/package/yuxi/agents/toolkits/registry.py`
### @tool 装饰器
```python
from yuxi.agents.toolkits.registry import tool
@tool(category="buildin", tags=["示例"], display_name="示例工具")
def example_tool(text: str) -> str:
"""示例工具:返回处理后的文本"""
...
```
装饰器参数:
- **category**: 工具分类,用于分组(`buildin``mysql``debug`
- **tags**: 标签列表,用于前端展示
- **display_name**: 显示名称(给人看的名字)
- **icon**: 图标名称(可选)
### 自动发现
导入 `toolkits` 包时会自动触发注册:
```python
from yuxi.agents.toolkits import buildin, mysql # 触发 @tool 装饰器执行
```
`toolkits/__init__.py` 中已包含 `buildin``mysql``debug` 模块的导入,这些模块加载时会自动注册所有带 `@tool` 装饰器的函数。
## 工具分类
### 内置工具 (buildin)
| 工具 | 说明 |
|------|------|
| `ask_user_question` | 向用户发起交互式提问 |
| `present_artifacts` | 展示 Agent 沙盒 outputs 目录下的产物文件 |
| `install_skill` | 从沙盒路径或 Git 来源安装当前用户私有 Skill,并激活当前主智能体会话;子智能体禁用 |
| `tavily_search` | Tavily 网页搜索(需配置 `TAVILY_API_KEY` |
Qwen-Image 生成能力已迁移为内置 Skill `image-gen`。模型调用与图片下载在 Agent 沙盒中完成,生成后的图片保存到 `/home/gem/user-data/outputs/`,再通过 `present_artifacts` 展示。
### MySQL 工具 (mysql)
| 工具 | 说明 |
|------|------|
| `mysql_list_tables` | 列出数据库中所有表 |
| `mysql_describe_table` | 获取表结构信息 |
| `mysql_query` | 执行只读 SQL 查询 |
### 知识库工具 (kbs)
知识库工具使用 `@tool(category="knowledge")` 注册,并通过内置 `knowledge-base` Skill 的 `tool_dependencies` 按需加载。`get_common_kb_tools()` 仍可用于直接获取完整工具列表:
```python
from yuxi.agents.toolkits.kbs import get_common_kb_tools
kb_tools = get_common_kb_tools()
# 返回: [list_kbs, get_mindmap, query_kb, find_kb_document, open_kb_document]
```
| 工具 | 说明 |
|------|------|
| `list_kbs` | 列出用户可访问的知识库 |
| `get_mindmap` | 获取知识库的思维导图结构 |
| `query_kb` | 在指定知识库中检索内容,返回结构化的 `resource_id`(即 `kb_id`/`file_id`/`chunk` |
| `find_kb_document` | 在已知文件内按关键词或正则定位内容 |
| `open_kb_document` | 按 `file_id` 分段打开知识库文档(默认窗口 1800 行) |
## 工具组装
工具组装在 Graph 创建阶段完成。内置 Agent 会先调用 `prepare_agent_runtime_context` 过滤当前用户可用资源,再调用 `resolve_configured_runtime_tools(context)` 加载已配置工具:
1. **基础工具**:从 `context.tools` 中按名称筛选
2. **MCP 工具**:根据 `context.mcps` 加载 MCP 服务器工具
3. **Skill 依赖工具**:由 `SkillsMiddleware` 在 Skill 激活后按需追加,包括 `knowledge-base` 绑定的知识库工具
```python
from yuxi.agents.context import prepare_agent_runtime_context
from yuxi.agents.toolkits.service import resolve_configured_runtime_tools
context = await prepare_agent_runtime_context(context, user=current_user, db=db)
tools = await resolve_configured_runtime_tools(context)
```
## Skills 集成
Skills 与工具是两种不同的扩展机制。工具是具体的功能实现,而 Skills 是包含提示词、工具依赖和元数据的完整技能包。通过 `context.skills` 配置 Skills 时,对应的技能文件会被挂载到沙盒的 `/home/gem/skills/<slug>/...`,智能体可以通过读取 SKILL.md 来了解如何使用这些技能。
关于 Skills 的详细机制,请参阅 [Skills 管理](./skills-management.md)。