.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 集成,到云效 API 的封装,最后三者串起来跑通——后面那些宏大叙事,都建立在今天这块地基上。
先看最终效果,在钉钉群里 @机器人:


@云效助手 下单页在 iOS 上白屏了,项目是商城,严重的话帮我建个缺陷,给陈珙
机器人回复:
创建成功,工作项地址:https://devops.aliyun.com/projex/project/xxx/bug/xxxx
是不是有点意思?下面开工。
目的与作用
先明确目标,避免自嗨式开发。这个 Agent 要解决的核心问题是:
把群里口语化的反馈,自动落地成云效里结构化的工作项。
拆开来看,它干了三件事:
- 听懂人话:群消息是口语化的(「白屏了」「帮我建个缺陷」),AI 负责提取出结构化信息——项目名、类型(Bug/需求/任务)、标题、负责人、描述。
- 会干活:AI 不是只会聊天,它通过 Function Calling(工具调用)直接操作云效 API——查项目、查成员、建工作项、查列表。
- 有上下文:同一个群是多轮会话。你没说清是哪个项目时它会追问一句,你补一句「商城」,它就能接着上文的语境继续把工作项建好,而不是每次都从头再来。
整体架构非常朴素:
技术选型三件套:
- 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 为例:
- 到开放平台注册并充值,创建一个 API Key(
sk-开头) - 记下三样东西:
ApiKey、Endpoint(https://api.deepseek.com)、Model(如deepseek-chat)
智谱、通义、Kimi 等国产模型都提供 OpenAI 兼容接口,换模型只改配置就行,这也是后面用
Microsoft.Extensions.AI.OpenAI抽象的好处。
2.2 钉钉应用(Stream 模式机器人)
传统钉钉机器人要走 HTTP 回调,需要有公网地址,内网开发很痛苦。Stream 模式通过 WebSocket 长连接反向连接钉钉服务端,本地就能调试,这也是选 Jusoft.DingtalkStream 的原因。
- 到钉钉开放平台创建一个企业内部应用
- 在「应用能力」里添加「机器人」
- 消息接收模式选择 Stream 模式
- 拿到应用的
ClientId和ClientSecret(应用凭证页面) - 发布应用,在群里把机器人添加进来



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/{这串就是}/ 的那段。


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,需要提供 spaceId、assignedTo(用户 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。
一次消息的完整旅程:
我有话想说
这个 Agent 当前也有绕不开的能力边界,对应的后续计划:
- 群聊总结建单:@机器人拿不到完整群聊历史,需结合钉钉 AI 小钉先做对话总结,再交给本 Agent 落单;
- 图片上传:富文本里的图片嵌入工作项描述——downloadCode 只是下载凭证,得走完下载链路再传云效附件;
- 上下文外置:会话历史迁到 Redis/数据库,重启不丢;
- 代码初判:按项目拉取代码仓库,AI 扫描疑似模块、把线索贴回工作项——前言那张图里挂在工作项后面的 Agent。
最后,说点感触。
边界不是墙,是插座。 「拿不到群聊历史」看起来是这个 Agent 的短板,但换一个视角:AI 小钉负责对话总结,本 Agent 负责结构化落单,一个 Agent 的边界恰好是另一个 Agent 的接入点。Agent 时代的架构设计,拼的就是把这些边界编排起来——单体的能力有限,组合的想象无限。
说到底,自动化的终点从来不是替代人。妄言用 AI 代替人,本身就是一种狂妄——机器人建好单、AI 给出初判线索,最后拍板排期、定责、取舍的仍然是你。工具越强,人的决策越值钱。 让信息自动流动,让人专注于判断——这就是我做这个小东西的全部初衷。
让 AI 代替人,本是不负责任的狂妄——重复给流程,繁琐给 AI,决策与创造留给人。
与诸君共勉。
作 者:
陈珙
出 处:http://www.cnblogs.com/skychen1218/
关于作者:专注于微软平台的项目开发。如有问题或建议,请多多赐教!
版权声明:本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是作者坚持原创和持续写作的最大动力!

浙公网安备 33010602011771号