拆解 OpenDeepWiki 的 Agent Skills 机制:从 SKILL.md 到 AI 工具调用的完整链路

最近在读 OpenDeepWiki 的源码,发现它实现了一套挺有意思的 Skill 扩展体系。简单说就是:你写一个 SKILL.md 文件,打包成 ZIP 上传,系统就能把它变成 AI Agent 可调用的工具。整个过程涉及文件解析、数据库持久化、运行时工具注入等多个环节,这篇文章把这条链路从头到尾拆一遍。

一、先搞清楚 Skill 是什么

OpenDeepWiki 的 Skill 遵循 agentskills.io 开放标准(Anthropic 提出的 Agent Skills 规范)。一个 Skill 本质上就是一个文件夹,核心是一个 SKILL.md 文件,里面用 YAML frontmatter 声明元数据,正文部分是给 AI 的 prompt 指令。

一个典型的 Skill 文件夹长这样:

复制代码
code-review/
├── SKILL.md          # 核心文件,frontmatter + prompt
├── scripts/          # 可选,辅助脚本
├── references/       # 可选,参考资料
└── assets/           # 可选,静态资源

SKILL.md 的格式大概是:

markdown 复制代码
---
name: code-review
description: 对代码进行深度审查,发现潜在问题和改进建议
license: MIT
compatibility: "gpt-4, claude-3"
allowed-tools: "read_file search_code"
metadata:
  author: token
  version: 1.0.0
---

你是一个代码审查专家。当用户请求代码审查时,请按以下步骤执行:

1. 先通读目标文件,理解整体结构
2. 检查常见问题:空指针、资源泄漏、并发安全...
3. 给出具体的改进建议,附带代码示例

frontmatter 里的 name 就是 Skill 的唯一标识,同时也是文件夹名,必须是 kebab-case 格式。description 会展示在工具列表里供 AI 选择。正文部分(--- 之后的内容)才是真正注入给 AI 的 prompt。

二、数据模型:SkillConfig 实体

Skill 的元数据存在数据库里,对应的实体类是 SkillConfig

csharp 复制代码
// src/OpenDeepWiki.Entities/Tools/SkillConfig.cs

public class SkillConfig : AggregateRoot<string>
{
    /// Skill 名称(唯一标识符,同时也是文件夹名)
    /// 规范:最大64字符,仅小写字母、数字和连字符
    [Required]
    [StringLength(64)]
    [RegularExpression(@"^[a-z0-9]+(-[a-z0-9]+)*$", 
        ErrorMessage = "名称只能包含小写字母、数字和连字符,且不能以连字符开头或结尾")]
    public string Name { get; set; } = string.Empty;

    [Required]
    [StringLength(1024)]
    public string Description { get; set; } = string.Empty;

    [StringLength(100)]
    public string? License { get; set; }

    [StringLength(500)]
    public string? Compatibility { get; set; }

    /// 预批准的工具列表(空格分隔)
    [StringLength(1000)]
    public string? AllowedTools { get; set; }

    /// Skill 文件夹的相对路径(相对于 skills 根目录)
    [Required]
    [StringLength(200)]
    public string FolderPath { get; set; } = string.Empty;

    public bool IsActive { get; set; } = true;
    public int SortOrder { get; set; } = 0;
    public string? Author { get; set; }
    public new string Version { get; set; } = "1.0.0";
    public SkillSource Source { get; set; } = SkillSource.Local;
    public string? SourceUrl { get; set; }

    // 文件夹结构标记
    public bool HasScripts { get; set; }
    public bool HasReferences { get; set; }
    public bool HasAssets { get; set; }
    public long SkillMdSize { get; set; }
    public long TotalSize { get; set; }
}

public enum SkillSource
{
    Local = 0,       // 本地上传
    Remote = 1,      // 从 URL 导入
    Marketplace = 2  // 从市场安装
}

几个值得注意的设计点:

  • Name 的正则校验^[a-z0-9]+(-[a-z0-9]+)*$,强制 kebab-case,跟文件夹名保持一致,避免路径问题
  • SkillSource 枚举 :预留了三种来源,目前主要用 Local,但架构上已经为远程导入和市场安装留了口子
  • HasScripts / HasReferences / HasAssets:记录文件夹结构,前端展示详情时不用再去扫磁盘

在 EF Core 的 MasterDbContext 里,SkillConfig 注册了 Name 唯一索引:

csharp 复制代码
// src/OpenDeepWiki.EFCore/MasterDbContext.cs

modelBuilder.Entity<SkillConfig>()
    .HasIndex(s => s.Name)
    .IsUnique();

三、Skill 的上传与解析:AdminToolsService

Skill 的生命周期管理在 AdminToolsService 里,这是整个系统里最"脏活累活"集中的地方------解压 ZIP、解析 YAML、校验格式、写磁盘、写数据库,一条龙。

3.1 上传流程

csharp 复制代码
// src/OpenDeepWiki/Services/Admin/AdminToolsService.cs

public async Task<SkillConfigDto> UploadSkillAsync(Stream zipStream, string fileName)
{
    var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
    Directory.CreateDirectory(tempDir);
    try
    {
        // 1. 解压到临时目录
        using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read))
            archive.ExtractToDirectory(tempDir);

        // 2. 找到 SKILL.md(支持根目录或一级子目录)
        var skillMdPath = FindSkillMd(tempDir) 
            ?? throw new InvalidOperationException("压缩包中未找到 SKILL.md");
        var skillRootDir = Path.GetDirectoryName(skillMdPath)!;

        // 3. 解析 frontmatter
        var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));

        // 4. 校验必填字段
        if (!frontmatter.TryGetValue("name", out var nameObj) 
            || string.IsNullOrEmpty(nameObj?.ToString()))
            throw new InvalidOperationException("SKILL.md 缺少 name 字段");

        var name = nameObj.ToString()!;
        if (!Regex.IsMatch(name, @"^[a-z0-9]+(-[a-z0-9]+)*$"))
            throw new InvalidOperationException("name 格式无效");

        if (!frontmatter.TryGetValue("description", out var descObj) 
            || string.IsNullOrEmpty(descObj?.ToString()))
            throw new InvalidOperationException("SKILL.md 缺少 description 字段");

        // 5. 查重
        if (await _context.SkillConfigs.AnyAsync(s => s.Name == name && !s.IsDeleted))
            throw new InvalidOperationException($"已存在同名 Skill: {name}");

        // 6. 移动到正式目录
        var targetPath = Path.Combine(_skillsBasePath, name);
        if (Directory.Exists(targetPath)) Directory.Delete(targetPath, true);
        Directory.Move(skillRootDir, targetPath);

        // 7. 构建实体并入库
        var config = new SkillConfig
        {
            Id = Guid.NewGuid().ToString(),
            Name = name,
            Description = descObj.ToString()!,
            License = frontmatter.TryGetValue("license", out var l) ? l?.ToString() : null,
            Compatibility = frontmatter.TryGetValue("compatibility", out var c) ? c?.ToString() : null,
            AllowedTools = frontmatter.TryGetValue("allowed-tools", out var t) ? t?.ToString() : null,
            FolderPath = name,
            IsActive = true,
            SortOrder = 0,
            Version = "1.0.0",
            Source = SkillSource.Local,
            HasScripts = Directory.Exists(Path.Combine(targetPath, "scripts")),
            HasReferences = Directory.Exists(Path.Combine(targetPath, "references")),
            HasAssets = Directory.Exists(Path.Combine(targetPath, "assets")),
            SkillMdSize = new FileInfo(Path.Combine(targetPath, "SKILL.md")).Length,
            TotalSize = CalculateDirectorySize(targetPath),
            CreatedAt = DateTime.UtcNow
        };

        _context.SkillConfigs.Add(config);
        await _context.SaveChangesAsync();
        // ... 返回 DTO
    }
    finally 
    { 
        if (Directory.Exists(tempDir)) 
            try { Directory.Delete(tempDir, true); } catch { } 
    }
}

这段代码的流程很清晰:解压 → 找 SKILL.md → 解析 YAML → 校验 → 查重 → 落盘 → 入库。有几个细节值得说说:

  1. FindSkillMd 支持两级查找:ZIP 包里 SKILL.md 可能在根目录,也可能在一级子目录下(比如你打包的时候多套了一层文件夹),它都能找到
  2. 临时目录用 GUID 命名:避免并发上传冲突
  3. finally 里静默删除临时目录catch { } 吞掉异常,因为清理失败不应该影响主流程

3.2 YAML Frontmatter 解析

csharp 复制代码
private static (Dictionary<string, object?> frontmatter, string body) ParseSkillMd(string content)
{
    var frontmatter = new Dictionary<string, object?>();
    var body = content;
    if (content.StartsWith("---"))
    {
        var endIndex = content.IndexOf("---", 3);
        if (endIndex > 0)
        {
            var yamlContent = content[3..endIndex].Trim();
            body = content[(endIndex + 3)..].Trim();
            try
            {
                var deserializer = new DeserializerBuilder()
                    .WithNamingConvention(HyphenatedNamingConvention.Instance)
                    .Build();
                frontmatter = deserializer.Deserialize<Dictionary<string, object?>>(yamlContent) ?? new();
            }
            catch { }
        }
    }
    return (frontmatter, body);
}

用的是 YamlDotNet 库,HyphenatedNamingConvention 对应 kebab-case 的 key 格式(比如 allowed-tools)。解析失败直接吞异常返回空字典------这个设计有点粗暴,但考虑到 frontmatter 里大部分字段都是可选的,也说得过去。

3.3 磁盘扫描刷新

除了上传,还有一个 RefreshSkillsFromDiskAsync 方法,用来扫描 skills 目录下的文件夹,把还没入库的 Skill 自动注册进去:

csharp 复制代码
public async Task RefreshSkillsFromDiskAsync()
{
    if (!Directory.Exists(_skillsBasePath)) return;

    var existingNames = (await _context.SkillConfigs
        .Where(s => !s.IsDeleted).ToListAsync())
        .Select(s => s.Name).ToHashSet();

    foreach (var dir in Directory.GetDirectories(_skillsBasePath))
    {
        var skillMdPath = Path.Combine(dir, "SKILL.md");
        if (!File.Exists(skillMdPath)) continue;

        var folderName = Path.GetFileName(dir);
        if (existingNames.Contains(folderName)) continue;

        try
        {
            var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));
            if (!frontmatter.TryGetValue("name", out var nameObj)) continue;
            var name = nameObj?.ToString();
            if (string.IsNullOrEmpty(name) || name != folderName) continue;
            // ... 构建 SkillConfig 并入库
        }
        catch (Exception ex) 
        { 
            _logger.LogWarning(ex, "解析失败: {Path}", dir); 
        }
    }
    await _context.SaveChangesAsync();
}

这个方法的使用场景是:你直接把 Skill 文件夹丢到 skills 目录下(比如通过 Docker volume 挂载),然后调一下刷新接口,系统就能识别到。注意它有个校验:文件夹名必须和 SKILL.md 里的 name 字段一致,不一致的会被跳过。

四、API 端点:Minimal API 风格

Skill 的管理接口在 AdminToolsEndpoints 里,用的是 ASP.NET Core 的 Minimal API:

csharp 复制代码
// src/OpenDeepWiki/Endpoints/Admin/AdminToolsEndpoints.cs

private static void MapSkillEndpoints(RouteGroupBuilder group)
{
    var skillGroup = group.MapGroup("/skills");

    // 列表
    skillGroup.MapGet("/", async ([FromServices] IAdminToolsService toolsService) =>
    {
        var result = await toolsService.GetSkillConfigsAsync();
        return Results.Ok(new { success = true, data = result });
    }).WithName("AdminGetSkills");

    // 详情
    skillGroup.MapGet("/{id}", async (string id, [FromServices] IAdminToolsService toolsService) =>
    {
        var result = await toolsService.GetSkillDetailAsync(id);
        return result != null 
            ? Results.Ok(new { success = true, data = result })
            : Results.NotFound(new { success = false, message = "Skill 不存在" });
    }).WithName("AdminGetSkillDetail");

    // 上传(ZIP)
    skillGroup.MapPost("/upload", async (HttpRequest request, [FromServices] IAdminToolsService toolsService) =>
    {
        // ... 校验 Content-Type、文件格式
        using var stream = file.OpenReadStream();
        var result = await toolsService.UploadSkillAsync(stream, file.FileName);
        return Results.Ok(new { success = true, data = result });
    }).WithName("AdminUploadSkill").DisableAntiforgery();

    // 更新(仅管理字段:IsActive、SortOrder)
    skillGroup.MapPut("/{id}", ...);

    // 删除(同时删除磁盘文件)
    skillGroup.MapDelete("/{id}", ...);

    // 读取 Skill 内部文件(带路径穿越防护)
    skillGroup.MapGet("/{id}/files/{*filePath}", ...);

    // 从磁盘刷新
    skillGroup.MapPost("/refresh", ...);
}

值得一提的是文件读取接口里的路径穿越防护:

csharp 复制代码
public async Task<string?> GetSkillFileContentAsync(string id, string filePath)
{
    var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);
    if (config == null) return null;

    var normalizedPath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath, filePath));
    var skillBasePath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath));

    // 关键:确保解析后的绝对路径在 Skill 目录内
    if (!normalizedPath.StartsWith(skillBasePath)) 
        throw new UnauthorizedAccessException("非法路径");

    return File.Exists(normalizedPath) ? await File.ReadAllTextAsync(normalizedPath) : null;
}

Path.GetFullPath../../etc/passwd 这类路径解析成绝对路径,然后检查是否还在 Skill 目录范围内。这是标准的路径穿越防御手法。

五、核心转换器:SkillToolConverter

到这里,Skill 已经存在磁盘和数据库里了。但 AI Agent 并不认识 SkillConfig,它只认 AIToolSkillToolConverter 就是做这个桥接的。

整个转换器的设计思路很巧妙:不是把每个 Skill 变成一个独立的 Tool,而是把所有 Skill 合并成一个叫 Skill 的 Tool。这个 Tool 的 description 里列出了所有可用 Skill 的名称和描述,AI 调用时传入 Skill 名称,Tool 就去读对应的 SKILL.md 返回 prompt 内容。

csharp 复制代码
// src/OpenDeepWiki/Services/Chat/SkillToolConverter.cs

public class SkillToolConverter : ISkillToolConverter
{
    private readonly IContext _context;
    private readonly ILogger<SkillToolConverter> _logger;
    private readonly string _skillsBasePath;

    public SkillToolConverter(
        IContext context,
        ILogger<SkillToolConverter> logger,
        IConfiguration configuration)
    {
        _context = context;
        _logger = logger;
        _skillsBasePath = configuration["Skills:BasePath"] 
            ?? Path.Combine(AppContext.BaseDirectory, "skills");
    }

    public async Task<List<AITool>> ConvertSkillConfigsToToolsAsync(
        List<string> skillIds,
        CancellationToken cancellationToken = default)
    {
        var tools = new List<AITool>();
        if (skillIds == null || skillIds.Count == 0) return tools;

        // 从数据库加载启用的 Skill 配置
        var skillConfigs = await _context.SkillConfigs
            .Where(s => skillIds.Contains(s.Id) && s.IsActive && !s.IsDeleted)
            .OrderBy(s => s.SortOrder)
            .ThenBy(s => s.Name)
            .ToListAsync(cancellationToken);

        if (skillConfigs.Count == 0) return tools;

        // 创建唯一的 LoadSkills 工具
        var loadSkillsTool = CreateLoadSkillsTool(skillConfigs);
        tools.Add(loadSkillsTool);

        _logger.LogInformation(
            "Created LoadSkills tool with {Count} available skills", 
            skillConfigs.Count);

        return tools;
    }
    // ...
}

5.1 构建 Tool 描述

CreateLoadSkillsTool 是最关键的方法。它做了两件事:构建一个包含 Skill 目录的 description,以及定义调用时的处理逻辑。

csharp 复制代码
private AITool CreateLoadSkillsTool(List<SkillConfig> skillConfigs)
{
    // 构建查找表
    var skillLookup = skillConfigs.ToDictionary(
        s => s.Name, s => s, StringComparer.OrdinalIgnoreCase);

    // 定义实际的处理函数
    var loadSkillAsync = async (
        [Description("The name of the skill to load (select from available skills listed in this tool's description)")] string name,
        CancellationToken cancellationToken) =>
    {
        return await LoadSkillInternalAsync(name, skillLookup);
    };

    // 构建 description,包含所有可用 Skill 的目录
    var description = new StringBuilder();
    description.AppendLine("""
Execute a skill within the main conversation
<skills_instructions>
When users ask you to perform tasks, check if any of the available skills
below can help complete the task more effectively. Skills provide specialized
capabilities and domain knowledge.
How to use skills:
- Invoke skills using this tool with the skill name only (no arguments)
- When you invoke a skill, you will see <command-message>The "{name}" skill is loading</command-message>
- The skill's prompt will expand and provide detailed instructions on how to complete the task
- Examples:
  - `skill: "pdf"` - invoke the pdf skill
  - `skill: "xlsx"` - invoke the xlsx skill
  - `skill: "ms-office-suite:pdf"` - invoke using fully qualified name

Important:
    - Only use skills listed in <available_skills> below
    - Do not invoke a skill that is already running
    - Do not use this tool for built-in CLI commands (like /help, /clear, etc.)
</skills_instructions>

<available_skills>
""");

    foreach (var skill in skillConfigs)
    {
        description.AppendLine($"- name: {skill.Name} - {skill.Description}");
    }
    description.Append("</available_skills>");

    // 用 AIFunctionFactory 创建 Tool
    return AIFunctionFactory.Create(
        loadSkillAsync,
        new AIFunctionFactoryOptions
        {
            Name = "Skill",
            Description = description.ToString()
        });
}

这段代码的精髓在于 description 的构造方式。它用 XML 标签(<skills_instructions><available_skills>)来结构化描述信息,这样 AI 模型能更准确地理解工具的用途和可选参数。每个 Skill 的 name 和 description 都列在里面,AI 看到用户的请求后,会自主判断要不要调用某个 Skill。

5.2 运行时加载 Skill Prompt

当 AI 决定调用 Skill 工具时,LoadSkillInternalAsync 负责读取 SKILL.md 并返回 prompt 正文:

csharp 复制代码
private async Task<string> LoadSkillInternalAsync(
    string name,
    Dictionary<string, SkillConfig> skillLookup)
{
    if (string.IsNullOrWhiteSpace(name))
    {
        return JsonSerializer.Serialize(new { error = true, message = "Skill name cannot be empty." });
    }

    if (!skillLookup.TryGetValue(name, out var skill))
    {
        var availableNames = string.Join(", ", skillLookup.Keys);
        return JsonSerializer.Serialize(new
        {
            error = true,
            message = $"Skill '{name}' not found. Available skills: {availableNames}"
        });
    }

    var skillMdPath = Path.Combine(_skillsBasePath, skill.FolderPath, "SKILL.md");
    if (!File.Exists(skillMdPath))
    {
        return JsonSerializer.Serialize(new { error = true, message = $"SKILL.md not found for skill '{name}'." });
    }

    try
    {
        var content = await File.ReadAllTextAsync(skillMdPath);
        // 去掉 YAML frontmatter,只返回 prompt 正文
        var prompts = ExtractPromptsBody(content);
        return prompts;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Failed to load SKILL.md for skill: {Name}", name);
        return JsonSerializer.Serialize(new { error = true, message = $"Failed to load skill: {ex.Message}" });
    }
}

private static string ExtractPromptsBody(string content)
{
    if (!content.StartsWith("---")) return content;

    var endIndex = content.IndexOf("---", 3);
    if (endIndex < 0) return content;

    return content[(endIndex + 3)..].Trim();
}

注意错误处理的方式:不是抛异常,而是返回 JSON 格式的错误信息。因为这个返回值会直接作为 Tool 的输出传回给 AI,AI 能理解这个错误并做出相应反应(比如换一个 Skill 试试,或者告诉用户 Skill 不存在)。

六、工具注入:两条消费路径

SkillToolConverter 产出的 AITool 会被注入到两个地方:Chat 对话助手Wiki 生成器

6.1 Chat 对话助手

ChatAssistantService.StreamChatAsync 里,Skill 工具和其他工具(Git 工具、文档阅读工具、MCP 工具)一起被组装:

csharp 复制代码
// src/OpenDeepWiki/Services/Chat/ChatAssistantService.cs

public async IAsyncEnumerable<SSEEvent> StreamChatAsync(
    ChatRequest request,
    CancellationToken cancellationToken = default)
{
    var config = await GetConfigAsync(cancellationToken);
    // ... 校验配置和模型

    var tools = new List<AITool>();

    // 1. Git 工具(读文件、搜索代码等)
    if (Directory.Exists(repositoryPath))
    {
        var gitTool = new GitTool(repositoryPath);
        tools.AddRange(gitTool.GetTools());
    }

    // 2. 文档阅读工具
    var chatDocReaderTool = await ChatDocReaderTool.CreateAsync(
        _context, request.Context.Owner, request.Context.Repo,
        request.Context.Branch, request.Context.Language, cancellationToken);
    tools.Add(chatDocReaderTool.GetTool());

    // 3. MCP 工具
    if (config.EnabledMcpIds.Count > 0)
    {
        var mcpTools = await _mcpToolConverter.ConvertMcpConfigsToToolsAsync(
            config.EnabledMcpIds, cancellationToken);
        tools.AddRange(mcpTools);
    }

    // 4. Skill 工具 ← 就是这里
    if (config.EnabledSkillIds.Count > 0)
    {
        var skillTools = await _skillToolConverter.ConvertSkillConfigsToToolsAsync(
            config.EnabledSkillIds, cancellationToken);
        tools.AddRange(skillTools);
    }

    // 5. 创建 Agent 并开始流式对话
    var agentOptions = new ChatClientAgentOptions
    {
        ChatOptions = new ChatOptions
        {
            Tools = tools.ToArray(),
            ToolMode = ChatToolMode.Auto,
            MaxOutputTokens = 32000
        }
    };

    var (agent, _) = _agentFactory.CreateChatClientWithTools(
        modelConfig.ModelId, tools.ToArray(), agentOptions, requestOptions);

    // ... 流式输出
}

config.EnabledSkillIds 来自 ChatAssistantConfig 表,管理员在后台配置哪些 Skill 对对话助手可用。

6.2 Wiki 生成器

WikiGenerator 里,Skill 工具的注入方式略有不同------它不依赖 ChatAssistantConfig,而是直接查所有启用的 Skill:

csharp 复制代码
// src/OpenDeepWiki/Services/Wiki/WikiGenerator.cs

private async Task<AITool[]> BuildToolsAsync(
    IEnumerable<AITool> baseTools,
    CancellationToken cancellationToken)
{
    var tools = baseTools.ToList();

    var enabledSkillIds = await GetEnabledSkillIdsAsync(cancellationToken);
    if (enabledSkillIds.Count == 0) return tools.ToArray();

    try
    {
        var skillTools = await _skillToolConverter.ConvertSkillConfigsToToolsAsync(
            enabledSkillIds, cancellationToken);

        if (skillTools.Count > 0)
        {
            tools.AddRange(skillTools);
            _logger.LogDebug("Added {SkillCount} skill tools to wiki generator", skillTools.Count);
        }
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "Failed to load skill tools for wiki generator");
    }

    return tools.ToArray();
}

private async Task<List<string>> GetEnabledSkillIdsAsync(CancellationToken cancellationToken)
{
    using var context = _contextFactory.CreateContext();
    return await context.SkillConfigs
        .Where(s => s.IsActive && !s.IsDeleted)
        .OrderBy(s => s.SortOrder)
        .ThenBy(s => s.Name)
        .Select(s => s.Id)
        .ToListAsync(cancellationToken);
}

Wiki 生成器里有个 try-catch 包裹 Skill 加载逻辑,Skill 加载失败不会阻断 Wiki 生成流程。这是个不错的容错设计------Skill 是锦上添花的东西,不应该因为它出问题就把核心功能搞挂。

七、Agent 工厂:最后一公里

所有工具最终都要通过 AgentFactory 注入到 AI Agent 里。AgentFactory 支持三种后端:OpenAI Chat、OpenAI Responses、Anthropic。

csharp 复制代码
// src/OpenDeepWiki/Agents/AgentFactory.cs

public (ChatClientAgent Agent, IList<AITool> Tools) CreateChatClientWithTools(
    string model,
    AITool[] tools,
    ChatClientAgentOptions clientAgentOptions,
    AiRequestOptions? requestOptions = null)
{
    var option = ResolveOptions(requestOptions ?? _options, true);

    clientAgentOptions.ChatOptions ??= new ChatOptions();
    clientAgentOptions.ChatOptions.Tools = tools;
    clientAgentOptions.ChatOptions.ToolMode = ChatToolMode.Auto;

    var agent = CreateAgentInternal(model, clientAgentOptions, option);
    return (agent, tools);
}

public static ChatClientAgent CreateAgentInternal(
    string model,
    ChatClientAgentOptions clientAgentOptions,
    AiRequestOptions options)
{
    // 先解析配置:合并传入参数、环境变量、默认值
    var option = ResolveOptions(options, true);
    var httpClient = CreateHttpClient();

    if (option.RequestType == AiRequestType.OpenAI)
    {
        var clientOptions = new OpenAIClientOptions()
        {
            Endpoint = new Uri(option.Endpoint ?? DefaultEndpoint),
            // 注意:OpenAI 分支同样注入了自定义 HttpClient(用于日志拦截等)
            Transport = new HttpClientPipelineTransport(httpClient)
        };
        var openAiClient = new OpenAIClient(
            new ApiKeyCredential(option.ApiKey ?? string.Empty), clientOptions);
        return openAiClient.GetChatClient(model).AsAIAgent(clientAgentOptions);
    }
    else if (option.RequestType == AiRequestType.OpenAIResponses)
    {
        // Responses 分支结构与 Chat 分支类似,但走 GetResponsesClient
        var clientOptions = new OpenAIClientOptions()
        {
            Endpoint = new Uri(option.Endpoint ?? DefaultEndpoint),
            Transport = new HttpClientPipelineTransport(httpClient)
        };
        var openAiClient = new OpenAIClient(
            new ApiKeyCredential(option.ApiKey ?? string.Empty), clientOptions);
        return openAiClient.GetResponsesClient(model).AsAIAgent(clientAgentOptions);
    }
    else if (option.RequestType == AiRequestType.Anthropic)
    {
        AnthropicClient client = new()
        {
            BaseUrl = option.Endpoint ?? DefaultEndpoint,
            ApiKey = option.ApiKey,
            HttpClient = httpClient,
        };
        clientAgentOptions.ChatOptions.ModelId = model;
        return client.AsAIAgent(clientAgentOptions);
    }

    throw new NotSupportedException("Unknown AI request type.");
}

几个要点:

  • ResolveOptions 会依次从传入参数、环境变量(CHAT_API_KEYENDPOINTCHAT_REQUEST_TYPE)、默认值中解析配置,保证即使调用方没传完整参数也能正常工作
  • 三个分支都通过自定义 HttpClient 注入了 LoggingHttpHandler,用于请求日志拦截,不只是 Anthropic 分支需要
  • ChatToolMode.Auto 意味着 AI 自己决定什么时候调用工具。Skill 工具的 description 里已经写清楚了使用场景,AI 会根据用户的请求自动判断是否需要加载某个 Skill

八、完整调用链路总结

把上面的内容串起来,一次 Skill 调用的完整链路是这样的:

复制代码
用户上传 ZIP
    ↓
AdminToolsService.UploadSkillAsync()
    → 解压 → 解析 SKILL.md → 校验 → 写磁盘 → 写数据库
    ↓
┌─────────────────────────────────────────────────────┐
│  路径 A:Chat 对话助手                                │
│  管理员在后台配置 ChatAssistantConfig.EnabledSkillIds  │
│  → 只有被显式选中的 Skill 才会注入                     │
├─────────────────────────────────────────────────────┤
│  路径 B:Wiki 生成器                                   │
│  直接查询所有 IsActive && !IsDeleted 的 Skill          │
│  → 不依赖 ChatAssistantConfig,所有启用的 Skill 都参与 │
└─────────────────────────────────────────────────────┘
    ↓
SkillToolConverter.ConvertSkillConfigsToToolsAsync()
    → 从数据库加载 SkillConfig
    → 构建 "Skill" AITool(description 含所有 Skill 目录)
    ↓
AgentFactory.CreateChatClientWithTools()
    → ResolveOptions() 合并配置
    → 创建 ChatClientAgent(OpenAI / OpenAIResponses / Anthropic)
    → Tools 注入到 ChatOptions
    ↓
AI Agent 运行
    → AI 根据用户请求判断是否需要调用 Skill
    → 调用 Skill("code-review")
    → LoadSkillInternalAsync() 读取 SKILL.md 正文
    → 返回 prompt 内容给 AI
    → AI 按照 prompt 指令执行任务

九、一些个人思考

读完这套代码,有几个地方觉得设计得不错:

  1. 单 Tool 聚合设计 :没有给每个 Skill 创建独立的 Tool,而是用一个 Skill Tool 做入口,通过 description 列出目录。这样不管有多少个 Skill,只占用一个 Tool 槽位,避免了 Tool 数量爆炸的问题(很多模型对 Tool 数量有限制)。

  2. Prompt 延迟加载:Skill 的 prompt 不是在构建 Tool 时就全部加载到 description 里,而是 AI 调用时才去读文件。这样 description 保持精简,不会因为 Skill 太多导致 context 膨胀。

  3. 磁盘 + 数据库双存储 :文件内容存磁盘(方便直接编辑和 volume 挂载),元数据存数据库(方便查询和管理)。两者通过 FolderPath 关联。

  4. 容错隔离:WikiGenerator 里 Skill 加载失败不影响主流程,ChatAssistantService 里 Skill 是可选的扩展能力。

也有一些可以改进的地方:

  • ParseSkillMd 里 YAML 解析失败直接吞异常,上传时还好(后面有校验),但 RefreshSkillsFromDiskAsync 里如果 YAML 格式有问题,用户完全不知道为什么 Skill 没被识别到
  • SkillToolConverter 每次调用都会查数据库,如果 Skill 列表不常变化,加个缓存会更好
  • AllowedTools 字段目前只是存了下来,并没有在运行时做任何权限控制

总的来说,这套 Skill 机制的设计思路很清晰:用文件系统做存储,用数据库做索引,用 Tool description 做发现,用延迟加载做注入。如果你也在做类似的 AI Agent 扩展系统,这个实现可以作为一个不错的参考。


如果这篇文章对你有帮助,欢迎 star OpenDeepWiki 项目。