.NET AI 实战篇:基于 Microsoft Agent Framework 集成钉钉机器⼈与云效项⽬管理
当前位置:点晴教程→知识管理交流
→『 技术文档交流 』
前言完整代码已经开源,搜索 Sky.DingTalk.AI 即可找到,欢迎 Star 和交流。
不知道大家有没有类似的经历:业务群里,业务同事跟 QA、产品围绕一个问题来回沟通,几轮下来结论清晰了——这是个 Bug,要排期修;或者这是个新需求,要立项评估。讨论的热乎劲儿刚过,接下来却是谁都不爱干的一步:有人得把这几屏聊天记录消化掉,打开云效(或者Oncs)、选项目、选类型、把讨论提炼成标题和描述、指派负责人……讨论越充分,搬运越痛苦。 我们团队的日常沟通都在钉钉,项目管理的云效,中间隔着一层「手工搬运」。这活儿机械、重复、还容易漏字段。于是我最初的目标很朴素:讨论定论后,在群里 @机器人 说一句话,它就帮我把工作项建好,顺便把地址发回来。 正好最近 Microsoft Agent Framework(Microsoft.Agents.AI)GA 了,配合 .NET 10 一拍即合,这两天把这个 Demo 拉通了。 但做着做着你会发现,「把口语落成结构化工作项」这个环节一旦打通,它就不只是一个省事的建单工具——它是整条 AI 研发流水线的第一个闸口。工作项是研发过程的结构化锚点,它后面还可以挂一整串 Agent:
用一张图表达这个愿景——本文打通的是第一环,后面的 Agent 都挂在「工作项」这个锚点上:
当信息能够在钉钉、云效、代码库之间自动流动,人就从「搬运工」退回到「决策者」的位置。 这篇文章先把这条流水线的第一环打通:从前期准备、钉钉机器人接入、AI Agent 集成,到云效 API 的封装,最后三者串起来跑通——后面那些宏大叙事,都建立在今天这块地基上。 先看最终效果,在钉钉群里 @机器人:
机器人回复:
是不是有点意思?下面开工。 目的与作用先明确目标,避免自嗨式开发。这个 Agent 要解决的核心问题是: 把群里口语化的反馈,自动落地成云效里结构化的工作项。 拆开来看,它干了三件事:
整体架构非常朴素:
技术选型三件套:
前期准备:AI、钉钉、云效的配置获取这一章没什么技术含量,但不做后面全卡。三家的「钥匙」都要先拿到手。 2.1 AI 接口(DeepSeek 为例)Agent 的大脑需要一个 OpenAI 兼容的 Chat Completions 接口,并且必须支持工具调用(Function Calling),这是整个方案的地基。 以 DeepSeek 为例:
2.2 钉钉应用(Stream 模式机器人)传统钉钉机器人要走 HTTP 回调,需要有公网地址,内网开发很痛苦。Stream 模式通过 WebSocket 长连接反向连接钉钉服务端,本地就能调试,这也是选 Jusoft.DingtalkStream 的原因。
2.3 云效(阿里云 Yunxiao)云效 Projex 的 OpenAPI 有两种认证方式,这里有个坑,后面封装时会展开:
个人推荐 PAT 方案:在云效「个人设置 → 个人访问令牌」页面直接生成,不用折腾阿里云主账号 AK,而且新接口(工作项类型、字段定义、极简创建)都在 oapi/v1 下。 还需要记下 OrganizationId(组织 ID):打开云效任意页面,URL 里
2.4 配置外置三家的凭证全部放进 {
"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);
}有个细节值得一提: 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 + 工具注册主角登场。 public DingTalkRobotAgent(IChatClient chatClient, YunxiaoClient yunxiao,
ILogger<DingTalkRobotAgent> logger)
{
_agent = new ChatClientAgent(
chatClient,
instructions: DefaultInstructions, // 系统提示词:角色 + 参数提取规则 + 追问策略
name: "dingtalk-robot-agent",
description: "钉钉群机器人助手……",
tools: YunxiaoAgentTools.Create(yunxiao)); // 云效工具集
}其中 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 多轮会话每个群独立会话,用
DeepSeek 走的是 Chat Completions,响应里没有会话 ID。框架每轮结束会「对账」:你声明了服务端管历史,但服务端没这个能力,于是直接抛 解决办法是无参创建会话 + 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 封装成独立的类库 4.1 双认证方案:一个客户端兼容两套网关前文提到的两套认证,在
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直接用 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( 封装 API 给 AI 用时,「减少参数」和「返回人话」是两个关键设计原则——参数越少,模型越不容易填错;返回文本化,模型直接转述,不用二次理解 JSON。 4.3 把客户端变成 Agent 工具
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」,模型会自然地转述给群里,不需要额外的错误处理链路。 类型参数还做了别名兼容( 三者的集成:组装起飞零件都齐了,最后用依赖注入把三者串起来。每个能力一个扩展方法, 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();注册顺序暗含依赖关系: 一次消息的完整旅程:
我有话想说这个 Agent 当前也有绕不开的能力边界,对应的后续计划:
最后,说点感触。 边界不是墙,是插座。 「拿不到群聊历史」看起来是这个 Agent 的短板,但换一个视角:AI 小钉负责对话总结,本 Agent 负责结构化落单,一个 Agent 的边界恰好是另一个 Agent 的接入点。Agent 时代的架构设计,拼的就是把这些边界编排起来——单体的能力有限,组合的想象无限。 说到底,自动化的终点从来不是替代人。妄言用 AI 代替人,本身就是一种狂妄——机器人建好单、AI 给出初判线索,最后拍板排期、定责、取舍的仍然是你。工具越强,人的决策越值钱。 让信息自动流动,让人专注于判断——这就是我做这个小东西的全部初衷。
阅读原文:点击这里 该文章在 2026/9/2 11:19:28 编辑过 |
关键字查询
相关文章
正在查询... |