.

by tcdos 2026-06-22

.NET Core 集成 AI Agent 实战:从 ChatClient 到工具调用与流式对话

在管理系统中引入 AI,最常见的需求就是"自然语言查询数据、执行操作"。本文以 EOMS 系统的 AI Agent 模块为样本,完整解析如何在 .NET 10 项目中集成大模型,从底层 ChatClient 配置到 Agent 编排器、工具系统、会话管理,再到前端 SignalR 流式对话的全链路实现。

一、架构全景

整个 AI Agent 模块分为四层:

前端 Chat.vue  ──→  REST API / SignalR AgentHub
                         │
                    AgentOrchestrator(编排器)
                         │
                    ┌────┴────┐
               ChatClient    IAgentToolProvider
          (OpenAI/DeepSeek)     ├─ SystemTools
                                ├─ QtzTools
                                └─ HelloTools

项目通过 COM.HS.Agent 类库封装了所有 Agent 相关示例逻辑。

COM.HS.Agent/
├── Agent/
│   └── AgentOrchestrator.cs      # Agent 编排器
├── Extensions/
│   ├── AgentServiceExtension.cs  # DI 注册
│   └── ChatClientFactory.cs      # AI 客户端工厂
├── Session/
│   ├── AgentSession.cs            # 会话实体
│   └── AgentSessionManager.cs     # 会话管理器
├── Tools/
│   ├── HelloAgentTools.cs         # 验证工具
│   ├── SystemAgentTools.cs        # 系统管理工具
│   └── QtzAgentTools.cs          # 定时任务工具
└── IAgentToolProvider.cs         # 工具提供者接口

二、AI 客户端:支持 DeepSeek / OpenAI 兼容协议

系统需要对接不同的大模型供应商——在实际项目中,可能使用 DeepSeek、通义千问、智谱等国产模型。这些模型大多数兼容 OpenAI 的协议格式。

2.1 ChatClientFactory

ChatClientFactory 支持 OpenAI 原生 SDK 的 ChatClient,支持完整工具调用(Function Calling):

public static class ChatClientFactory
{
    public static ChatClient CreateNativeChatClient(IConfigurationSection aiConfig)
    {
        var endpoint = aiConfig["Endpoint"];
        var apiKey = aiConfig["ApiKey"];
        var model = aiConfig["Model"];

        var credential = new ApiKeyCredential(apiKey);

        var client = string.IsNullOrWhiteSpace(endpoint)
            ? new OpenAIClient(credential)                          // 默认 OpenAI
            : new OpenAIClient(credential, new OpenAIClientOptions
            {
                Endpoint = new Uri(endpoint.TrimEnd('/') + "/")    // 自定义端点
            });

        return client.GetChatClient(model);
    }
}

如果 endpoint 为空,连接 OpenAI 官方 API;如果配置了 endpoint(如 https://api.deepseek.com/),则连接 DeepSeek 或其他兼容协议的服务。一套代码,多模型兼容

同时还会创建一个 IChatClient 实例(MEAI 抽象层),供通用场景使用:

services.TryAddSingleton<IChatClient>(sp =>
{
    var native = sp.GetRequiredService<ChatClient>();
    return native.AsIChatClient();          // OpenAI SDK → MEAI 标准接口
});

2.2 配置

appsettings.json 中只需要几行配置:

"AI": {
    "Provider": "DeepSeek",
    "Endpoint": "https://api.deepseek.com/",
    "ApiKey": "sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "Model": "deepseek-v4-flash"
}

更换模型供应商时,只需改 EndpointModel,代码不需要任何修改。


三、Agent 编排器(AgentOrchestrator)

编排器是整个 AI Agent 的核心,封装了 AIAgent 的创建、工具注入和会话上下文管理。

3.1 核心逻辑

public sealed class AgentOrchestrator
{
    private readonly IEnumerable<IAgentToolProvider> _toolProviders;
    private readonly ChatSessionManager _sessionManager;
    private readonly IChatClient _chatClient;

    // 缓存 AIAgent 实例(单例即可,不含会话状态)
    private AIAgent? _cachedAgent;
    private readonly object _lock = new();
    // ...
}

四个关键依赖:工具提供者集合会话管理器AI 客户端缓存 Agent 实例

3.2 线程安全的 Agent 创建

private AIAgent GetAgent()
{
    if (_cachedAgent != null) return _cachedAgent;

    lock (_lock)    // 双重检查锁定,确保线程安全
    {
        if (_cachedAgent != null) return _cachedAgent;

        var tools = _toolProviders
            .SelectMany(p => p.GetTools())     // 合并所有工具
            .ToArray();

        _cachedAgent = _chatClient.AsAIAgent(
            instructions: """
                你是企业运维管理平台 EOMS 的 AI 智能助手。

                能力范围:
                - 系统管理:查询用户、角色、部门、API 接口
                - 人力资源管理:请假申请、查询请假记录
                - 财务管理:报销查询、提交报销
                - 工作流引擎:查询待审批任务、审批通过/驳回、查询流程进度
                - 定时任务:查询任务状态、运行/停止任务

                回答规则:
                - 有可用的工具时,必须优先使用工具获取数据,不要编造信息
                - 对于不确定的问题,坦诚表示无法回答
                - 回答简洁专业
                - 请使用中文回复
                """,
            name: "EOMSAgent",
            tools: tools);      // 工具注入

        return _cachedAgent;
    }
}

这部分有几个关键设计点:

  • 双重检查锁定(Double-Checked Locking):AIAgent 实例本身不持有会话状态,可以全局共享,因此用 lock 做线程安全的懒加载
  • SelectMany 合并工具:所有实现了 IAgentToolProvider 的类,它们的工具被合并到一个数组注入到 Agent 中。新增一个工具类,自动注册、自动合并
  • System Prompt(instructions):定义了 AI 的角色定位、能力范围和回答规则。"有可用的工具时,必须优先使用工具获取数据,不要编造信息"——这句规则正是避免 LLM 幻觉的关键

3.3 非流式对话(REST API)

public async Task<string> ChatAsync(string sessionId, string message, string userId)
{
    var session = await _sessionManager.GetOrCreateAsync(sessionId, userId);
    var mafSession = await GetMafSessionAsync(session);   // 获取 MAF 会话
    var agent = GetAgent();

    var response = await agent.RunAsync(message, mafSession);
    return response.Text;
}

3.4 流式对话(SignalR)

public async IAsyncEnumerable<string> ChatStreamingAsync(string sessionId, string message, string userId)
{
    var session = await _sessionManager.GetOrCreateAsync(sessionId, userId);
    var mafSession = await GetMafSessionAsync(session);
    var agent = GetAgent();

    await foreach (var update in agent.RunStreamingAsync(message, mafSession))
    {
        yield return update.Text ?? string.Empty;   // 逐块返回
    }
}

四、工具系统(IAgentToolProvider)

这是 Agent 与业务系统对接的核心接口。

4.1 接口定义

public interface IAgentToolProvider
{
    /// <summary>获取工具定义集合</summary>
    IEnumerable<AIFunction> GetTools();
}

接口极其精简——只要求实现 GetTools() 方法,返回 AIFunction 集合。AIFunction 是 MEAI(Microsoft.Extensions.AI)标准类型,通过 AIFunctionFactory.Create 从方法引用自动生成。

4.2 自动发现与注册

工具提供者的注册使用了 Scrutor(一个程序集扫描库):

// AgentServiceExtension.cs
services.Scan(scan => scan
    .FromAssembliesOf(typeof(IAgentToolProvider))
    .AddClasses(classes => classes.AssignableTo(typeof(IAgentToolProvider)))
    .As<IAgentToolProvider>()
    .WithScopedLifetime());

这意味着:所有实现了 IAgentToolProvider 的类,都会被自动扫描并注册到 DI 容器。新增一个工具类,只需要在新的文件中实现接口,不需要在注册代码中显式添加一行。

4.3 SystemAgentTools:系统管理工具

public sealed class SystemAgentTools : IAgentToolProvider
{
    private readonly IUserService _userService;
    private readonly IRoleService _roleService;
    private readonly IDeptService _deptService;
    private readonly IApiService _apiService;
    // ...

    public IEnumerable<AIFunction> GetTools()
    {
        yield return AIFunctionFactory.Create(
            SearchUsersAsync,
            name: "SearchUsers",
            description: "搜索用户列表,支持按关键词、部门、启用状态筛选");

        yield return AIFunctionFactory.Create(
            GetUserDetailAsync,
            name: "GetUserDetail",
            description: "根据用户ID获取用户详细信息");

        yield return AIFunctionFactory.Create(
            GetRolesAsync,
            name: "GetRoles",
            description: "获取角色列表,可按关键词搜索");

        yield return AIFunctionFactory.Create(
            GetDeptTreeAsync,
            name: "GetDeptTree",
            description: "获取部门列表");

        yield return AIFunctionFactory.Create(
            GetApisAsync,
            name: "GetApis",
            description: "获取API接口列表,可按关键词搜索");
    }
    // ...
}

AIFunctionFactory.Create 通过反射自动解析方法的参数和返回值,构建 OpenAI Function Calling 所需的 JSON Schema。参数的 [Description] 特性会被自动提取,作为 Schema 的描述信息。

4.4 QtzAgentTools:定时任务工具

另一个工具提供者,管理 Quartz 定时任务的增删启停:

public sealed class QtzAgentTools : IAgentToolProvider
{
    public IEnumerable<AIFunction> GetTools()
    {
        yield return AIFunctionFactory.Create(
            GetTaskListAsync, name: "GetTaskList",
            description: "查询定时任务列表,可按关键词搜索");

        yield return AIFunctionFactory.Create(
            RunTaskAsync, name: "RunTask",
            description: "运行/启动一个定时任务");

        yield return AIFunctionFactory.Create(
            StopTaskAsync, name: "StopTask",
            description: "停止一个正在运行的定时任务");

        yield return AIFunctionFactory.Create(
            RunTaskNowAsync, name: "RunTaskNow",
            description: "立即执行一次定时任务,不修改调度计划");
    }
}

RunTaskAsync 为例,AI 调用它的流程是:

用户问:帮我启动日志清理任务
    ↓
AI 分析意图 → 匹配 RunTask 工具
    ↓
提取参数 taskId → 从问题中推断
    ↓
调用 RunTaskAsync(taskId)
    ↓
后端启动 Quartz 触发器 → 更新数据库状态 → 返回结果
    ↓
AI 组织自然语言回复:已成功启动「日志清理」任务

4.5 HelloAgentTools:验证工具

一个轻量工具,用于验证 AI 链路是否正常联通:

public sealed class HelloAgentTools : IAgentToolProvider
{
    public IEnumerable<AIFunction> GetTools()
    {
        yield return AIFunctionFactory.Create(
            GetCurrentUser, name: "GetCurrentUser",
            description: "获取当前登录用户的信息");

        yield return AIFunctionFactory.Create(
            GetCurrentTime, name: "GetCurrentTime",
            description: "获取当前服务器时间");
    }

    [Description("返回当前登录用户的 UserId 和基本信息")]
    public string GetCurrentUser()
    {
        return $"当前用户: {_user.UserId}, 姓名: {_user.UserName}";
    }

    [Description("返回当前服务器时间")]
    public string GetCurrentTime()
    {
        return DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
    }
}

这两个工具不需要依赖任何业务服务,在开发调试期用于快速定位问题:是 AI 连接不通还是工具执行报错。


五、会话管理(ChatSessionManager)

多轮对话需要上下文记忆。Agent 使用了 MAF(Microsoft.Agents.Framework)的 AgentSession 来维护对话历史。

5.1 会话实体

public class ChatSession
{
    public string SessionId { get; set; }
    public string UserId { get; set; }
    public string? Title { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime LastActivityAt { get; set; }
    public bool IsArchived { get; set; }

    /// <summary>MAF 会话对象(持有对话历史)</summary>
    public AgentSession? MafSession { get; set; }
}

5.2 会话管理器(内存实现)

public sealed class ChatSessionManager
{
    private readonly IMemoryCache _cache;
    private readonly TimeSpan _sessionTtl = TimeSpan.FromHours(2);

    public Task<ChatSession> GetOrCreateAsync(string sessionId, string userId)
    {
        var cacheKey = $"agent_session_{sessionId}";

        if (_cache.TryGetValue<ChatSession>(cacheKey, out var session) && session != null)
        {
            session.LastActivityAt = DateTime.UtcNow;   // 续期
            return Task.FromResult(session);
        }

        var newSession = new ChatSession { ... };
        _cache.Set(cacheKey, newSession, _sessionTtl);  // 2 小时过期
        return Task.FromResult(newSession);
    }
}

当前使用 IMemoryCache 存储会话,优点是零配置、无侵入。但注意注释提到"后续可扩展为 DB 持久化"——当会话数据量增大或需要跨服务共享时,只需将 IMemoryCache 替换为 Redis 或其他分布式缓存。

5.3 MAF 会话的懒加载

private async Task<AgentSession> GetMafSessionAsync(ChatSession session)
{
    if (session.MafSession != null)  // 已有 MAF 会话,直接复用
        return session.MafSession;

    var agent = GetAgent();
    var mafSession = await agent.CreateSessionAsync();  // 创建新会话
    session.MafSession = mafSession;                    // 缓存
    return mafSession;
}

这个懒加载设计确保了:每个 ChatSession 在第一次调用时才创建对应的 MAF AgentSession。并且一旦创建,在整个会话生命周期内复用同一个 MAF 会话实例,从而保持对话上下文的连续性。


六、消息投递:REST API + SignalR 双通道

Agent 提供了两种交互方式:REST API 非流式SignalR 流式

6.1 REST API:AgentController

适用于"发送消息,一次性获取完整回复"的场景:

[Route("api/agent")]
[ApiController]
[Authorize]
public class AgentController : ControllerBase
{
    [Route("chat")]
    [HttpPost]
    public async Task<ResultModel<string>> Chat([FromBody] AgentChatRequest request)
    {
        var sessionId = request.SessionId ?? Guid.NewGuid().ToString("N");
        var result = await _orchestrator.ChatAsync(
            sessionId, request.Message, _user.UserId);
        return new ResultModel<string>(true, result);
    }

    [Route("session")]
    [HttpGet]
    public ResultModel<string> NewSession()
    {
        return new ResultModel<string>(true, Guid.NewGuid().ToString("N"));
    }
}

两个接口:/api/agent/chat 发起对话,/api/agent/session 获取新会话 ID。

6.2 SignalR 流式:AgentHub

适用于"逐字显示 AI 回复"的实时体验:

[Authorize]
public class AgentHub : Hub
{
    public async Task Chat(string sessionId, string message)
    {
        var userId = Context.UserIdentifier ?? "anonymous";

        try
        {
            await foreach (var chunk in _orchestrator.ChatStreamingAsync(sessionId, message, userId))
            {
                await Clients.Caller.SendAsync("AgentChunk", new
                {
                    sessionId,
                    content = chunk,
                    isComplete = false
                });
            }

            // 发送完成标记
            await Clients.Caller.SendAsync("AgentChunk", new
            {
                sessionId,
                content = string.Empty,
                isComplete = true
            });
        }
        catch (Exception ex)
        {
            await Clients.Caller.SendAsync("AgentChunk", new
            {
                sessionId,
                content = $"错误: {ex.Message}",
                isComplete = true,
                isError = true
            });
        }
    }
}

流式对话的实现使用了 C# 8 的 IAsyncEnumerable 配合 await foreachAgentOrchestrator.ChatStreamingAsync 每次 yield return 一个文本块,AgentHub 立即通过 SignalR 推送给前端。

注意这里的前端调用方是 Clients.Caller——只推送给发起对话的当前用户,而不是广播给所有人。


七、前端 Chat.vue:完整对话界面

前端 AI 对话页面使用 Vue3 + SignalR(流式)+ REST(非流式)实现。

7.1 消息结构

// 消息对象结构
{
    role: 'user' | 'assistant',
    content: string,          // 消息内容
    isLoading: boolean,       // 是否正在加载(流式时显示打字动画)
    timestamp: number,        // 时间戳
}

7.2 发送流程

async function handleSend() {
    const text = inputMessage.value.trim();
    if (!text || isLoading.value) return;
    inputMessage.value = '';

    if (!currentSessionId.value) await initSession();

    // 1. 添加用户消息
    messages.value.push({ role: 'user', content: text, timestamp: Date.now() });

    // 2. 添加空的助手消息(显示打字动画)
    const assistantMsg = { role: 'assistant', content: '', isLoading: true, timestamp: Date.now() };
    messages.value.push(assistantMsg);
    scrollToBottom();

    // 3. 调用 REST API(非流式)
    isLoading.value = true;
    try {
        const res = await chat({
            message: text,
            sessionId: currentSessionId.value,
        });
        assistantMsg.content = res.data?.data || '暂无回复';
    } catch (err) {
        assistantMsg.content = '网络请求异常:' + (err.message || '');
    } finally {
        assistantMsg.isLoading = false;  // 移除打字动画
        isLoading.value = false;
        scrollToBottom();
    }
}

当前版本使用的是 REST API(非流式),但界面已经为流式做好了准备——isLoading 控制打字动画,content 逐块追加即可转流式。

7.3 Markdown 渲染

AI 回复的内容是 Markdown 格式,通过 marked 库渲染为 HTML:

import { marked } from 'marked';

marked.setOptions({ breaks: true, gfm: true });

function renderMarkdown(content) {
    if (!content) return '';
    try {
        return marked.parse(content);
    } catch {
        return content;
    }
}

在模板中使用 v-html 渲染:

<div class="message-text markdown-body" v-html="renderMarkdown(msg.content)"></div>

页面 CSS 专门为 Markdown 输出做了全面适配,覆盖了 h1-h6、代码块(暗色背景)、表格、引用、列表、任务清单等几乎所有 Markdown 语法。代码块使用了深色背景 #1e293b,配合高亮配色。

7.4 快捷提问

用户首次打开对话时,页面展示 4 个快捷问题卡片:

<div class="quick-card" @click="sendQuick('查询定时任务列表')">
    <span class="quick-label">查询定时任务</span>
</div>
<div class="quick-card" @click="sendQuick('列出所有角色')">
    <span class="quick-label">列出所有角色</span>
</div>
<div class="quick-card" @click="sendQuick('列出所有部门')">
    <span class="quick-label">列出所有部门</span>
</div>
<div class="quick-card" @click="sendQuick('现在几点了')">
    <span class="quick-label">现在几点了</span>
</div>

其中"现在几点了"对应 HelloAgentTools.GetCurrentTime,"查询定时任务列表"对应 QtzAgentTools.GetTaskListAsync——这些快捷问题覆盖了不同工具提供者,方便测试验证。


八、DI 注册全景

全部基础设施在 AgentServiceExtension.AddAgent() 中完成:

public static IServiceCollection AddAgent(this IServices)
{
    // 1. OpenAI SDK 原生 ChatClient(工具调用完整支持)
    services.TryAddSingleton<ChatClient>(sp => {
        var config = sp.GetRequiredService<IConfiguration>();
        return ChatClientFactory.CreateNativeChatClient(config.GetSection("AI"));
    });

    // 2. MEAI IChatClient(通用层,基于原生 ChatClient 包装)
    services.TryAddSingleton<IChatClient>(sp => {
        var native = sp.GetRequiredService<ChatClient>();
        return native.AsIChatClient();
    });

    // 3. AgentOrchestrator (Scoped)
    services.TryAddScoped<AgentOrchestrator>();

    // 4. 会话管理 (Scoped)
    services.TryAddScoped<ChatSessionManager>();

    // 5. 自动扫描注册所有 IAgentToolProvider
    services.Scan(scan => scan
        .FromAssembliesOf(typeof(IAgentToolProvider))
        .AddClasses(classes => classes.AssignableTo(typeof(IAgentToolProvider)))
        .As<IAgentToolProvider>()
        .WithScopedLifetime());

    return services;
}

在 Program.cs 中一行启用:

/* Agent */
builder.Services.AddAgent();

DI 的生命周期选择:

  • ChatClient/IChatClient:Singleton(全局单例,有连接池)
  • AgentOrchestrator:Scoped(每个请求一个实例,内部的 Agent 缓存是线程安全的)
  • ChatSessionManager:Scoped(注入 IMemoryCache,它是 Singleton,所以 Scoped 不影响缓存效果)
  • IAgentToolProvider 实现:Scoped(它们注入的 Service 是 Scoped)

九、扩展一个新工具只需两步

以新加一个"人力资源管理"工具为例:

第一步:创建工具类

public sealed class HrAgentTools : IAgentToolProvider
{
    private readonly ILeaveService _leaveService;

    public IEnumerable<AIFunction> GetTools()
    {
        yield return AIFunctionFactory.Create(
            GetLeaveBalanceAsync,
            name: "GetLeaveBalance",
            description: "查询当前员工的请假余额");
    }

    public async Task<string> GetLeaveBalanceAsync(string userId)
    {
        var balance = await _leaveService.GetBalanceAsync(userId);
        return JsonSerializer.Serialize(balance);
    }
}

第二步:注册到第一个工具提供者集合

由于 services.Scan 自动扫描了所有 IAgentToolProvider 的实现,新增的 HrAgentTools 会被自动发现和注册。不需要修改任何现有代码

然后 AIAgent 在下次重建时(当前使用缓存,重启即生效)就会自动包含新工具。这就是依赖注入 + 程序集扫描带来的扩展性。


十、技术栈与依赖

Agent 模块的依赖清单:

<PackageReference Include="Microsoft.Agents.AI" Version="1.9.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.9.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.7.0" />
<PackageReference Include="OpenAI" Version="2.11.0" />
<PackageReference Include="Scrutor" Version="6.0.1" />
  • Microsoft.Agents.AI:MAF 框架,提供 AIAgentAgentSession 等核心抽象
  • Microsoft.Extensions.AI.OpenAI:MEAI 标准接口的 OpenAI 实现
  • OpenAI SDK:底层的 OpenAI API 客户端(支持 Function Calling 的完整协议)
  • Scrutor:程序集扫描,自动注册所有 IAgentToolProvider

十一、小结

设计维度实现方案核心价值
AI 供应商ChatClientFactory 工厂 + appsettings.json 配置切换模型只需改配置
Agent 编排AgentOrchestrator + AIAgent统一管理工具注入
System Promptinstructions 字符串定义角色、能力、回答规则
工具系统IAgentToolProvider + Scrutor 自动扫描新增工具零代码侵入
工具注册AIFunctionFactory.Create 从方法生成反射自动解析 JSON Schema
会话管理ChatSessionManager + MAF AgentSession多轮上下文记忆
投递方式REST API + SignalR 双通道流式/非流式按需切换
前端渲染marked Markdown 渲染 + 打字动画用户体验流畅
自动发现Scrutor 程序集扫描扩展工具实现即可

整个 Agent 集成的设计核心是接口标准化 + 自动发现:所有 AI 供应商通过统一的 ChatClient 接口接入,所有工具通过统一的 IAgentToolProvider 接口暴露,模块之间通过依赖注入组合。这使得添加一个新的 AI 能力,往往只需要"实现一个接口 + 写一个工具方法"两步。


欢迎关注 天草设计 公众号,领取 源代码 福利礼包!

围观:27| 收获点赞:0

评论 0

暂无评论,来说点什么吧

?