想象一下:
你对 AI Agent 说:"帮我写份技术文档",它立刻加载 technical-writer.skill,变身技术作家。
说完文档,你又说:"分析一下竞品的广告策略",它换上 marketing-analyst.skill,开始爬数据。
更酷的是,如果你需要一个特定领域的专家——比如"SQL 性能优化"——而市面上没有?让 Agent 自己写一个 skill 给自己用。
这不是科幻。实现起来,极其简单。
这篇文章会教你构建一个 Universal Agent:
.skill 文件,Agent 立刻获得新能力把它想象成组建一支 10 人的创始团队:每个技能就是一个专家,Agent 是项目经理,你是 CEO。
核心组件:
bash-tool - 内存文件系统,支持 bash、readFile、writeFilesandbox/user/{id}/skills/(只读,来自 S3)sandbox/user/{id}/workspace/(可读写,持久化)每个 skill 是一个 .skill 文件(zip 格式),结构简单:
SKILL.md 示例:
上传流程:用户上传 zip → 存储到 S3 → 数据库记录 → Agent 按需加载。
文件:app/api/chat/universal/route.ts
关键点:
skills/ 前缀下(只读)onFinish 将所有变更写回磁盘文件:lib/skill/workspace.ts
工作流程:
文件:app/prompt/index.ts
关键设计:
考虑过的方案:
bash-tool 的优势:
理由:
Claude 模型在训练时就使用 XML 结构处理工具和 artifacts。这种格式:
挑战: bash-tool 使用原生模块(@mongodb-js/zstd、node-liblzma)做压缩。
解决方案: 不复制原生模块——它们是可选的。
next.config.ts:
基准测试(M1 Mac,50 个 skills,200 个 workspace 文件):
| 操作 | 时间 | 备注 |
|---|---|---|
| 加载 skills + workspace | 120ms | 并行 I/O |
| 创建 10 个文件 | 5ms | 内存操作 |
| 保存 workspace | 80ms | 写入磁盘 |
| 导出文件夹(50 个文件) | 150ms | Zip 创建 |
优化建议:
扩展系统:
核心理念: 每个用户都能成为拥有完整工程团队的创始人。每个技能就是一个专家。Agent 负责协调。Workspace 持久化。可能性无限。
src/app/(universal)/api/chat/universal/route.tssrc/lib/skill/workspace.tssrc/lib/skill/loadToMemory.tssrc/app/(universal)/tools/exportFolder.tssrc/app/(universal)/prompt/index.tssrc/app/(universal)/README.md基于 Next.js 15、Vercel AI SDK、bash-tool 和 Claude 4.5 Sonnet 构建。
用户上传 skill 包 → S3 存储 ↓请求开始 → 加载 skills + workspace → 内存沙箱 ↓Agent 读取技能、创建文件、使用工具 ↓请求结束 → 保存 workspace 变更 → 磁盘 ↓下次对话 → 文件持久化 → Agent 继续工作my-skill/├── SKILL.md # 指令和专业知识└── references/ # 可选:参考文档 └── examples.md---name: technical-writerdescription: 创建技术文档、API 指南和教程---# Technical Writer Skill你是一位专业技术写作专家,擅长创建清晰简洁的文档。## 专业领域- API 文档(带示例)- 架构决策记录(ADR)- 用户指南和教程- 代码注释和内联文档## 写作准则- 先讲为什么,再讲是什么,最后讲怎么做- 提供实用示例- 使用主动语态- 保持 DRY——用链接而非重复## 激活方式当用户说:- "写一份文档..."- "创建 API 指南..."- "为这个代码库写文档..."import { streamText } from "ai";import { createBashTool } from "bash-tool";import { loadAllSkillsToMemory } from "@/lib/skill/loadToMemory";import { loadUserWorkspace, saveUserWorkspace } from "@/lib/skill/workspace";export async function POST(req: Request) { const { userId, userChatId } = await authenticate(req); // 从 S3/磁盘加载 skills const skills = await prisma.agentSkill.findMany({ where: { userId } }); const skillFiles = await loadAllSkillsToMemory(skills); // 加载持久化的 workspace const workspaceFiles = await loadUserWorkspace(userId); // 给 skills 加前缀实现隔离 const skillFilesWithPrefix = Object.fromEntries( Object.entries(skillFiles).map(([path, content]) => [`skills/${path}`, content]) ); // 创建沙箱 const { tools: bashTools, sandbox } = await createBashTool({ files: { ...workspaceFiles, // 用户的工作文件(根目录) ...skillFilesWithPrefix, // Skills(skills/ 子目录) }, }); // 合并所有工具 const tools = { ...baseTools, // webSearch, reasoningThinking 等 bash: bashTools.bash, readFile: bashTools.readFile, writeFile: bashTools.writeFile, exportFolder: exportFolderTool({ sandbox, userId }), }; // 流式响应 + 持久化 const result = streamText({ model: llm("claude-sonnet-4-5"), system: buildSystemPrompt({ skills, locale }), messages: await loadMessages(userChatId, { tools }), tools, onStepFinish: async (step) => { await saveStepToDB(step); await trackTokens(step); }, onFinish: async () => { // 持久化 workspace 变更 await saveUserWorkspace(userId, sandbox); }, }); return result.toUIMessageStreamResponse();}import type { Sandbox } from "bash-tool";export async function loadUserWorkspace(userId: number): Promise<Record<string, string>> { const workspacePath = getWorkspacePath(userId); // .next/cache/sandbox/user/{id}/workspace const files: Record<string, string> = {}; await loadDirectoryRecursively(workspacePath, "", files); return files;}export async function saveUserWorkspace(userId: number, sandbox: Sandbox): Promise<void> { const workspacePath = getWorkspacePath(userId); // 获取所有非 skills/ 的文件 const findResult = await sandbox.executeCommand( `find . -type f ! -path "./skills/*" 2>/dev/null || echo ""` ); const filePaths = findResult.stdout.split("\n").filter(Boolean); // 清空 workspace 并保存最新状态 await fs.rm(workspacePath, { recursive: true, force: true }); await fs.mkdir(workspacePath, { recursive: true }); for (const filePath of filePaths) { const content = await sandbox.readFile(filePath); const fullPath = path.join(workspacePath, filePath.replace(/^\.\//, "")); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, content); }}export async function buildUniversalSystemPrompt({ userId, locale, skills }) { const skillsXml = skills.map(s => `<skill> <name>${s.name}</name> <description>${s.description}</description> <location>skills/${s.name}/SKILL.md</location></skill> `).join('\n'); return `你是一个拥有专业技能的 Universal Agent。## 可用技能${skillsXml}## Workspace 结构\`\`\`sandbox/├── skills/ # 只读技能(来自 S3)│ ├── technical-writer/│ └── market-researcher/└── my-project/ # 持久化工作空间 └── README.md\`\`\`## 如何使用技能1. **加载技能**:\`cat skills/technical-writer/SKILL.md\`2. **扮演角色**:完全遵循技能的指令3. **使用参考资料**:\`cat skills/technical-writer/references/examples.md\`## 文件操作- **创建**:\`writeFile({ path: "project/index.js", content: "..." })\`- **读取**:\`cat project/index.js\` 或 \`readFile({ path: "project/index.js" })\`- **导出**:\`exportFolder({ folderPath: "project" })\` 供用户下载根目录的所有文件都会在对话间持久化。## 准则- 用户请求专业工作时加载相应技能- 精确遵循技能指令——这是你的专业知识- 在根目录创建文件(不在 skills/ 下)- 使用 bash 命令探索(ls、find、grep 等)`;}用户:"为我们的支付系统写 API 文档"Agent: 1. cat skills/technical-writer/SKILL.md 2. [加载技能,扮演技术作家角色] 3. writeFile({ path: "docs/api-reference.md", content: "..." }) 4. writeFile({ path: "docs/examples.md", content: "..." }) 5. exportFolder({ folderPath: "docs" })→ 用户下载完整文档包用户:"研究 AI 编程工具市场,创建报告"Agent: 1. cat skills/market-researcher/SKILL.md 2. webSearch({ query: "AI coding tools 2025 市场分析" }) 3. [分析结果,综合洞察] 4. writeFile({ path: "research/market-analysis.md", content: "..." }) 5. [用户中断对话,去开会]稍后:用户:"添加竞争格局章节"Agent: 1. cat research/market-analysis.md # 文件还在! 2. [继续处理现有文件] 3. writeFile({ path: "research/market-analysis.md", content: "..." }) 4. exportFolder({ folderPath: "research" })用户:"创建一个 SQL 查询优化的技能"Agent: 1. mkdir -p sql-optimizer 2. writeFile({ path: "sql-optimizer/SKILL.md", content: "..." }) 3. writeFile({ path: "sql-optimizer/references/patterns.md", content: "..." }) 4. exportFolder({ folderPath: "sql-optimizer" })用户:下载,上传为 .skill 文件→ 现在可以在 skills/ 目录中使用了✅ 当前方案:skills/(只读)+ workspace/(可读写)❌ 备选方案:所有内容都在根目录问题:Agent 可能意外覆盖 skills解决:清晰分离,在 prompt 中明确说明// ❌ 增量方案:跟踪并只保存变更的文件await saveChangedFiles(changedFiles);// ✅ 完整同步:保存完整 workspace 状态await saveEntireWorkspace(allFiles);<skill> <name>technical-writer</name> <description>创建技术文档</description> <location>skills/technical-writer/SKILL.md</location></skill># DockerfileCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static# 注意:不复制原生压缩模块# - exportFolder 使用 jszip(纯 JS)# - just-bash 有 JS 降级方案处理压缩# 如果需要在沙箱中使用 tar -z,取消注释:# COPY --from=deps /app/node_modules/.pnpm/@mongodb-js+zstd@*/node_modules/@mongodb-js ./node_modules/@mongodb-jswebpack: (config, { isServer, webpack }) => { if (isServer) { // 只外部化原生二进制文件 config.externals.push("@mongodb-js/zstd", "node-liblzma"); // 忽略仅浏览器的 worker.js config.plugins.push( new webpack.IgnorePlugin({ resourceRegExp: /^\.\/worker\.js$/, contextRegExp: /just-bash/, }) ); } return config;}// 定期清理旧的导出文件export async function cleanupOldExports() { const exportsDir = path.join(process.cwd(), ".next/cache/sandbox"); const cutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 小时 for (const userId of await fs.readdir(path.join(exportsDir, "user"))) { const exportsPath = getExportsPath(Number(userId)); for (const file of await fs.readdir(exportsPath)) { const stat = await fs.stat(path.join(exportsPath, file)); if (stat.mtimeMs < cutoff) { await fs.unlink(path.join(exportsPath, file)); } } }}