.NET AI 实战篇:基于 Microsoft Agent Framework 集成钉钉机器⼈与云效项⽬管理

前言

完整代码已经开源,搜索 Sky.DingTalk.AI 即可找到,欢迎 Star 和交流。

https://github.com/SkyChenSky/Sky.DingTalk.AI

不知道大家有没有类似的经历:业务群里,业务同事跟 QA、产品围绕一个问题来回沟通,几轮下来结论清晰了——这是个 Bug,要排期修;或者这是个新需求,要立项评估。讨论的热乎劲儿刚过,接下来却是谁都不爱干的一步:有人得把这几屏聊天记录消化掉,打开云效(或者Oncs)、选项目、选类型、把讨论提炼成标题和描述、指派负责人……讨论越充分,搬运越痛苦。

我们团队的日常沟通都在钉钉,项目管理的云效,中间隔着一层「手工搬运」。这活儿机械、重复、还容易漏字段。于是我最初的目标很朴素:讨论定论后,在群里 @机器人 说一句话,它就帮我把工作项建好,顺便把地址发回来。 正好最近 Microsoft Agent Framework(Microsoft.Agents.AI)GA 了,配合 .NET 10 一拍即合,这两天把这个 Demo 拉通了。

但做着做着你会发现,「把口语落成结构化工作项」这个环节一旦打通,它就不只是一个省事的建单工具——它是整条 AI 研发流水线的第一个闸口。工作项是研发过程的结构化锚点,它后面还可以挂一整串 Agent:

  • Bug 建完只是开始:Agent 根据工作项所属项目拉取代码仓库,让 AI 扫描相关模块,把「疑似出问题的文件、最近的变更记录」贴回工作项描述——开发还没打开 IDE,排查线索已经就位;
  • 需求立项即预估:结合历史相似需求做影响面分析,给出改动范围和涉及模块,辅助排期决策;
  • 订单(数据)查找:根据同事给的订单号,通过MCP由AI进行整理

用一张图表达这个愿景——本文打通的是第一环,后面的 Agent 都挂在「工作项」这个锚点上:

愿景图:业务群讨论经 AI Agent 落成云效工作项,后续可挂载代码扫描初判、影响面分析等 Agent,最终由人做决策

当信息能够在钉钉、云效、代码库之间自动流动,人就从「搬运工」退回到「决策者」的位置。 这篇文章先把这条流水线的第一环打通:从前期准备、钉钉机器人接入、AI Agent 集成,到云效 API 的封装,最后三者串起来跑通——后面那些宏大叙事,都建立在今天这块地基上。

先看最终效果,在钉钉群里 @机器人:

 微信截图_20260819103039

微信图片_20260819102828

@云效助手 下单页在 iOS 上白屏了,项目是商城,严重的话帮我建个缺陷,给陈珙

机器人回复:

创建成功,工作项地址:https://devops.aliyun.com/projex/project/xxx/bug/xxxx

是不是有点意思?下面开工。

目的与作用

先明确目标,避免自嗨式开发。这个 Agent 要解决的核心问题是:

把群里口语化的反馈,自动落地成云效里结构化的工作项。

拆开来看,它干了三件事:

  1. 听懂人话:群消息是口语化的(「白屏了」「帮我建个缺陷」),AI 负责提取出结构化信息——项目名、类型(Bug/需求/任务)、标题、负责人、描述。
  2. 会干活:AI 不是只会聊天,它通过 Function Calling(工具调用)直接操作云效 API——查项目、查成员、建工作项、查列表。
  3. 有上下文:同一个群是多轮会话。你没说清是哪个项目时它会追问一句,你补一句「商城」,它就能接着上文的语境继续把工作项建好,而不是每次都从头再来。

整体架构非常朴素:

整体架构图:钉钉群经 Stream 长连接接入 .NET 宿主程序,内部含钉钉 Stream 客户端、ChatClientAgent、YunxiaoClient,分别对接 DeepSeek 大模型与云效 OpenAPI

技术选型三件套:

  • Jusoft.DingtalkStream:钉钉官方 Stream 模式的 .NET 社区封装,免去自建 WebSocket 和暴露公网回调地址
  • Microsoft.Agents.AI:微软新出的 Agent 框架,ChatClientAgent + 工具注册,几行代码就有一个能调工具的 Agent
  • Sikiro.YunXiao:自己封装的云效 OpenAPI 客户端类库(独立项目,可复用)

前期准备:AI、钉钉、云效的配置获取

这一章没什么技术含量,但不做后面全卡。三家的「钥匙」都要先拿到手。

2.1 AI 接口(DeepSeek 为例)

Agent 的大脑需要一个 OpenAI 兼容的 Chat Completions 接口,并且必须支持工具调用(Function Calling),这是整个方案的地基。

以 DeepSeek 为例:

  1. 到开放平台注册并充值,创建一个 API Key(sk- 开头)
  2. 记下三样东西:ApiKeyEndpointhttps://api.deepseek.com)、Model(如 deepseek-chat

智谱、通义、Kimi 等国产模型都提供 OpenAI 兼容接口,换模型只改配置就行,这也是后面用 Microsoft.Extensions.AI.OpenAI 抽象的好处。

2.2 钉钉应用(Stream 模式机器人)

传统钉钉机器人要走 HTTP 回调,需要有公网地址,内网开发很痛苦。Stream 模式通过 WebSocket 长连接反向连接钉钉服务端,本地就能调试,这也是选 Jusoft.DingtalkStream 的原因。

  1. 钉钉开放平台创建一个企业内部应用
  2. 在「应用能力」里添加「机器人」
  3. 消息接收模式选择 Stream 模式
  4. 拿到应用的 ClientId 和 ClientSecret(应用凭证页面)
  5. 发布应用,在群里把机器人添加进来

image

image

 

image

2.3 云效(阿里云 Yunxiao)

云效 Projex 的 OpenAPI 有两种认证方式,这里有个坑,后面封装时会展开:

方案凭证网关特点
方案一 AK AccessKeyId / Secret devops.cn-hangzhou.aliyuncs.com 阿里云 V2.0 签名,老版 ROA 接口居多
方案二 PAT 个人访问令牌 openapi-rdc.aliyuncs.com x-yunxiao-token 请求头,无需签名,oapi/v1 新接口

个人推荐 PAT 方案:在云效「个人设置 → 个人访问令牌」页面直接生成,不用折腾阿里云主账号 AK,而且新接口(工作项类型、字段定义、极简创建)都在 oapi/v1 下。

还需要记下 OrganizationId(组织 ID):打开云效任意页面,URL 里 organizations/{这串就是}/ 的那段。

image

image

 

 

2.4 配置外置

三家的凭证全部放进 appsettings.json,不进源码:

{
  "DingTalk": {
    "ClientId": "dingxxxxxxxx",
    "ClientSecret": "xxxxxxxx"
  },
  "Ai": {
    "ApiKey": "sk-xxxxxxxx",
    "Endpoint": "https://api.deepseek.com",
    "Model": "deepseek-chat"
  },
  "Yunxiao": {
    "OrganizationId": "xxxxxxxx",
    "PersonalAccessToken": "pt-xxxxxxxx"
  }
}

钉钉机器人与 AI 的集成

前期工作就绪后,我第一版 Demo 是「钉钉消息 → AI → 回复」先跑通,再把云效工具挂上去(对应仓库里「完成 demo」「调通了」那几个提交)。这个顺序建议大家都这么走:先让消息链路通,再让 Agent 有本事

3.1 消息处理器:模板方法模式

钉钉收到群消息后的处理骨架是固定的(模板模式):过滤 → 提取 → 生成回答 → 回复 → 应答。这里用了个模板方法模式的基类,把骨架钉死,子类只关心「怎么生成回答」:

public abstract class RobotMessageHandlerBase : IDingtalkStreamMessageHandler
{
    public async Task HandleMessage(MessageEventHanderArgs e)
    {
        if (!CanHandle(e)) return;                          // 1. 只处理机器人消息回调

        var message = e.GetRobotMessageData();
        var content = GetTextContent(message);             // 2. 提取消息文本

        var answer = await ProcessAsync(message, content); // 3. 生成回答(子类实现)

        await ReplyAsync(message, answer);                 // 4. sessionWebhook 回复
        await AckAsync(e);                                 // 5. ack 应答,否则服务端会重推
    }

    protected abstract Task<string> ProcessAsync(ReceivedRobotMessage message, string content);
}

有个细节值得一提:GetTextContent 要按消息类型分派。钉钉群里发文字是 text,转发、引用、Markdown 是 richText(富文本分段),纯图片之类的其他类型我们暂时不处理:

protected virtual string GetTextContent(ReceivedRobotMessage message)
    => message.MsgType?.ToLowerInvariant() switch
    {
        "text" => message.GetTextContent().Content ?? "",
        "richText" => string.Concat(message.GetRichTextContent().RichText?.Select(r => r.Text) ?? []),
        _ => "",
    };

具体的处理器就薄得只剩一行了:

public class DingTalkRobotMessageHandler(DingTalkRobotAgent agent)
    : RobotMessageHandlerBase
{
    protected override Task<string> ProcessAsync(ReceivedRobotMessage message, string content)
        => agent.AskAsync(message.ConversationId, content, message.SenderNick);
}

3.2 Agent:ChatClientAgent + 工具注册

主角登场。Microsoft.Agents.AI 的 ChatClientAgent 把「大模型 + 提示词 + 工具集」打包成一个 Agent 对象:

public DingTalkRobotAgent(IChatClient chatClient, YunxiaoClient yunxiao,
    ILogger<DingTalkRobotAgent> logger)
{
    _agent = new ChatClientAgent(
        chatClient,
        instructions: DefaultInstructions,   // 系统提示词:角色 + 参数提取规则 + 追问策略
        name: "dingtalk-robot-agent",
        description: "钉钉群机器人助手……",
        tools: YunxiaoAgentTools.Create(yunxiao));  // 云效工具集
}

其中 IChatClient 来自 Microsoft.Extensions.AI.OpenAI,任何 OpenAI 兼容的模型都能接:

services.AddSingleton<IChatClient>(_ =>
    new OpenAIClient(new ApiKeyCredential(ai.ApiKey), new OpenAIClientOptions
    {
        Endpoint = new Uri(ai.Endpoint),
    }).GetChatClient(ai.Model).AsIChatClient());

提示词是 Agent 的灵魂,我的策略是「能默认就默认,只有项目名完全无法确定才追问」——群里没人喜欢跟机器人一问一答填表单:

你是钉钉群里的「云效项目助手」,帮助团队成员把口语化的反馈落地成云效工作项。

处理用户消息的规则:
1. 从消息中提取:项目名、工作项类型(Bug 缺陷 / Req 需求 / Task 任务)、标题、负责人、描述。
2. 信息不全时优先用合理默认值,不要向用户二次确认:
   - 类型未提及 → 默认 Bug;
   - 描述未提及 → 把用户的原话整理成描述;
   - 负责人未提及 → 默认用消息标注的「发起人」(@机器人的用户)。
3. 只有当「项目名」完全无法确定时,才回复用户请他补充是哪个项目;
   用户补充后必须结合上下文继续处理,不要重复追问。
4. 不确定项目名是否真实存在时,先调用 ListProjects 核对(宁可多查一次,不要猜)。
5. 创建成功后,回复一句话结果并附上工作项地址(URL)。

3.3 多轮会话

每个群独立会话,用 ConcurrentDictionary<群ID, AgentSession> 按群保存即可。有个小提醒:CreateSessionAsync 如果传入 conversationId,会走框架的「服务端托管聊天历史」模式——历史存在模型服务商那边,客户端每次只带会话 ID。但这套模式要求后端协议本身支持「服务端存对话」:

协议代表服务端管历史?
Responses API OpenAI / Azure OpenAI ✅ 响应带 conversation_id,历史存在服务端
Chat Completions DeepSeek / 智谱 等 OpenAI 兼容端点 ❌ 无状态,每次请求要带全量历史

DeepSeek 走的是 Chat Completions,响应里没有会话 ID。框架每轮结束会「对账」:你声明了服务端管历史,但服务端没这个能力,于是直接抛 Service did not return a valid conversation id...——宁可报错也不静默丢历史。

解决办法是无参创建会话 + SetInMemoryChatHistory 在应用侧自己管历史——每轮把全量历史重新发给模型,恰好和 Chat Completions 的无状态协议是天生一对:

private async Task<AgentSession> GetOrCreateSessionAsync(string conversationId, CancellationToken ct)
{
    if (_sessions.TryGetValue(conversationId, out var session))
    {
        // 未超上限直接复用
        if (!session.TryGetInMemoryChatHistory(out var history) || history.Count <= MaxSessionMessages)
            return session;

        // 超过上限:丢弃最旧的消息,保留最近 N 条(滑动窗口)
        var trimmed = history.Skip(history.Count - MaxSessionMessages).ToList();
        // 截断处可能落在「工具调用 / 工具结果」中间,开头的孤儿 tool 消息会被接口拒绝,
        // 因此向前推进到第一条用户消息
        var firstUser = trimmed.FindIndex(m => m.Role == ChatRole.User);
        session.SetInMemoryChatHistory(firstUser > 0 ? trimmed.Skip(firstUser).ToList() : trimmed);
        return session;
    }

    session = await _agent.CreateSessionAsync(ct);   // 注意:必须无参
    session.SetInMemoryChatHistory([]);              // 应用侧托管聊天历史
    return _sessions[conversationId] = session;
}

每个群最多保留 40 条消息(约 20 轮),超过后丢弃最旧的、保留最近 40 条(滑动窗口),上下文不会突然全丢,token 也不会无限膨胀。截断时留意别切在一对「工具调用/工具结果」中间——开头留下孤儿 tool 消息会被接口拒绝,所以截断后从第一条用户消息开始保留。

云效 API 的封装

AI 有了,但它还只会说不会做。这一章把云效 OpenAPI 封装成独立的类库 Sikiro.YunXiao(对应仓库里「重构完成」「完成重构」两个提交——Demo 跑通后我把客户端从主工程拆成了独立项目,可复用)。

4.1 双认证方案:一个客户端兼容两套网关

前文提到的两套认证,在 YunxiaoClient 构造时二选一,之后所有方法自动路由:

  • 方案一(AK):用阿里云 V2.0 通用 SDK 做泛化调用(ROA 风格),不需要安装云效产品 SDK,SDK 自动完成 ACS V3 签名
  • 方案二(PAT):裸 HttpClient + x-yunxiao-token 请求头直连,无需任何签名
public YunxiaoClient(YunxiaoOptions options)
{
    if (!string.IsNullOrEmpty(options.AccessKeyId) && !string.IsNullOrEmpty(options.AccessKeySecret))
        _akClient = new Client(new Config { ... });   // 方案一
    else if (!string.IsNullOrEmpty(options.PersonalAccessToken))
        { }                                            // 方案二:PAT,无签名
    else
        throw new ArgumentException("必须提供 AK(方案一)或 PersonalAccessToken(方案二)");
}

两套网关不只是认证不同,接口路径和请求体结构也有差异(这是云效 API 的历史包袱,不是我们设计的问题)。比如创建工作项:

// 方案一:POST /organization/{orgId}/workitems/create
body["space"] = spaceId; body["spaceIdentifier"] = spaceId; body["spaceType"] = "Project";
body["descriptionFormat"] = "MARKDOWN";

// 方案二:POST /oapi/v1/projex/organizations/{orgId}/workitems
body["spaceId"] = spaceId;
body["formatType"] = "MARKDOWN";

对于这种「本质复杂度」,我的做法是老老实实在方法内分支,只把真正重复的部分(如单个工作项的 URL 拼接)抽成私有方法,不过度设计。

4.2 极简创建:QuickCreateWorkItemAsync

直接用 CreateWorkItemAsync 建一个 Bug,需要提供 spaceIdassignedTo(用户 ID)、workitemTypeId、必填自定义字段(比如 Bug 的「严重程度」选项 ID)……这对 AI 来说太不友好了。于是封装了一个极简版本,调用方只传大类、标题、项目名,其余全部自动推断:

public async Task<string> QuickCreateWorkItemAsync(
    YunxiaoWorkItemCategory category, string subject, string projectName,
    string? assignedTo = null, string? description = null, CancellationToken ct = default)
{
    // 1. 按名称找项目 → spaceId
    // 2. 负责人:传用户 ID 或姓名都行,自动解析;不传取项目第一个成员
    // 3. 类型:该大类下的默认类型
    // 4. 必填自定义字段(严重程度等):统一取字段配置的默认选项
    // 5. 创建并回查,返回工作项地址(可直接点开)
}

返回的是工作项 URL(https://devops.aliyun.com/projex/project/{spaceId}/bug/{id}),AI 拿到后直接贴在群里,体验闭环。

封装 API 给 AI 用时,「减少参数」和「返回人话」是两个关键设计原则——参数越少,模型越不容易填错;返回文本化,模型直接转述,不用二次理解 JSON。

4.3 把客户端变成 Agent 工具

Microsoft.Extensions.AI 的 AIFunctionFactory.Create 能把普通方法变成 Agent 可调用的工具,[Description] 特性就是给大模型看的「说明书」,写得越清楚模型调用越准:

public static IList<AITool> Create(YunxiaoClient client)
{
    var functions = new YunxiaoToolFunctions(client);
    return
    [
        AIFunctionFactory.Create(functions.ListProjects),
        AIFunctionFactory.Create(functions.ListProjectMembers),
        AIFunctionFactory.Create(functions.CreateWorkItem),
        AIFunctionFactory.Create(functions.ListWorkItems),
    ];
}

private sealed class YunxiaoToolFunctions(YunxiaoClient client)
{
    [Description("在云效创建工作项(Bug 缺陷 / Req 需求 / Task 任务),成功后返回可直接打开的工作项地址。")]
    public async Task<string> CreateWorkItem(
        [Description("工作项类型:Bug=缺陷、Req=需求、Task=任务")] string category,
        [Description("标题:一句话概括问题或需求")] string subject,
        [Description("项目名称:必须是云效中真实存在的项目名,不确定时先用 ListProjects 查询")] string projectName,
        [Description("负责人姓名,可不传,默认取消息标注的发起人(@机器人的用户)")] string? assignedTo = null,
        [Description("详细描述(Markdown),可不传")] string? description = null)
    {
        if (!TryParseCategory(category, out var parsed))
            return $"创建失败:无法识别的工作项类型「{category}」";
        try
        {
            var url = await client.QuickCreateWorkItemAsync(parsed, subject, projectName, assignedTo, description);
            return $"创建成功,工作项地址:{url}";
        }
        catch (YunxiaoException ex)
        {
            return $"创建失败:{ex.Message}";   // 失败原因转成文本,模型会转告用户
        }
    }
}

注意所有工具的返回值都是中文文本而不是对象——失败时返回「创建失败:未找到项目 xxx」,模型会自然地转述给群里,不需要额外的错误处理链路。

类型参数还做了别名兼容(bug/缺陷/Bug 都认识),进一步降低模型出错的概率。

三者的集成:组装起飞

零件都齐了,最后用依赖注入把三者串起来。每个能力一个扩展方法,Program.cs 干净得像目录:

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((ctx, services) => services
        .AddYunxiao(o =>
        {
            o.OrganizationId = ctx.Configuration["Yunxiao:OrganizationId"]!;
            o.PersonalAccessToken = ctx.Configuration["Yunxiao:PersonalAccessToken"]!;
        })
        .AddDingTalkRobotAgent(ai =>
        {
            ai.ApiKey = ctx.Configuration["Ai:ApiKey"]!;
            ai.Endpoint = ctx.Configuration["Ai:Endpoint"]!;
            ai.Model = ctx.Configuration["Ai:Model"]!;
        })
        .AddDingTalkRobot<DingTalkRobotMessageHandler>(dingTalk =>
        {
            dingTalk.ClientId = ctx.Configuration["DingTalk:ClientId"]!;
            dingTalk.ClientSecret = ctx.Configuration["DingTalk:ClientSecret"]!;
        }))
    .Build();

Console.WriteLine("DingTalk Stream 机器人已启动(云效 AI Agent)");
await host.RunAsync();

注册顺序暗含依赖关系:AddYunxiao 提供 YunxiaoClient → AddDingTalkRobotAgent 拿它构造 Agent → AddDingTalkRobot 挂上消息处理器,处理器构造函数注入 Agent。

一次消息的完整旅程:

时序图:用户在群里 @机器人,经钉钉 Stream 推送到消息处理器,Agent 结合 DeepSeek 分析后调用云效创建工作项,最后通过 sessionWebhook 回复并应答

我有话想说

这个 Agent 当前也有绕不开的能力边界,对应的后续计划:

  • 群聊总结建单:@机器人拿不到完整群聊历史,需结合钉钉 AI 小钉先做对话总结,再交给本 Agent 落单;
  • 图片上传:富文本里的图片嵌入工作项描述——downloadCode 只是下载凭证,得走完下载链路再传云效附件;
  • 上下文外置:会话历史迁到 Redis/数据库,重启不丢;
  • 代码初判:按项目拉取代码仓库,AI 扫描疑似模块、把线索贴回工作项——前言那张图里挂在工作项后面的 Agent。

最后,说点感触。

边界不是墙,是插座。 「拿不到群聊历史」看起来是这个 Agent 的短板,但换一个视角:AI 小钉负责对话总结,本 Agent 负责结构化落单,一个 Agent 的边界恰好是另一个 Agent 的接入点。Agent 时代的架构设计,拼的就是把这些边界编排起来——单体的能力有限,组合的想象无限。

说到底,自动化的终点从来不是替代人。妄言用 AI 代替人,本身就是一种狂妄——机器人建好单、AI 给出初判线索,最后拍板排期、定责、取舍的仍然是你。工具越强,人的决策越值钱。 让信息自动流动,让人专注于判断——这就是我做这个小东西的全部初衷。

让 AI 代替人,本是不负责任的狂妄——重复给流程,繁琐给 AI,决策与创造留给人。

与诸君共勉。

 

posted @ 2026-08-19 11:13  陈珙  阅读(152)  评论(4)    收藏  举报