需求说明

因为日常工作建 Jira 卡太麻烦,要选一堆字段,而且还要自己写上下文、AC 等内容。为了减轻自己和团队的工作量,我想创建一个 Slack 机器人,能够简单的通过发消息来完成建卡自动化。

同时为了方便使用,这个机器人要满足下面的需求:

  • 和用户打招呼
  • 提供使用帮助,包括机器人能够处理什么样的命令,每个命令的用法等
  • 能够根据 thread 上下文快速 jira 建卡
  • 后续功能扩展

虽说初衷是为处理 Slack 消息开发的,但本文并不会讨论 Slack 集成的相关内容。这里只是记录一下设计和开发思路,因此也适用于其他通过自然语言输入来完成 AI 交互的开发场景。

效果展示

机器人最终执行效果如下:

发送消息后返回建卡数据发送消息后返回建卡数据

我给后台代码添加了 Dry run 模式, 只输出创建 Jira 卡的参数, 避免在测试过程中真的去建一堆不需要的卡片.

流程设计

我们要处理的是自然语言的输入(NLP)。比较明确的流程思路是:

  1. 提取自然语言输入消息中的信息
  2. 将信息进行结构化转换,便于程序处理
  3. 根据结构化数据,调用对应的功能处理模块
  4. 在功能模块中对数据进行必要的整理、转换等操作,如果有必要,再进一步交给外部服务处理,比如调用 Jira API 来创建 Ticket。
  5. 根据处理结果,生成返回的消息或数据,作为操作反馈发回给调用方。

得益于 LLM 的快速发展,第 1、2 步我们完全可以委托给 LLM 处理。自己造轮子做 NLP 不但费事费力,而且效果不一定让人满意。而关键字或正则匹配更是没法满足需求,因为用户发来的消息千奇百怪,很难通过硬编码匹配所有情况,更别提还要处理语义分析、近义/同义词识别等问题。

Round 1

我的第一版实现是给 LLM 一个大而全的 instruction,让它一次性生成完整的结果数据,并通过里面的一些分类/标记字段来区分后续操作。

instruction 大概长这样:

# 目标
解析输入消息中的关键信息,分析用户进行后续操作所需的数据。
返回的基础数据结构如下:
{
purpose: string;
verb: string;
subject: string;
}
根据以下规则填入字段值:
- purpose:要执行的操作类型,用户的主要意图
- hello: 打招呼
- help: 寻求帮助或回答问题
- jira: 进行 jira 相关操作
- verb:要执行的操作动作,分析输入中的主要动词并填入
- subject: 操作面向的目标
# Jira 操作
##创建 Jira 卡片
如果用户的意图是创建或报告一个 Jira 卡片,则返回如下的数据结构:
{
purpose: "jira";
verb: "create";
subject: "ticket";
title: string;
type: "task"|"story"|"bug"|"epic";
context: string;
(其他字段略)
}
字段生成规则:
- 将与创建/报告含义近似的动词,都映射为 "create"。
- (后续关于其他字段的生成规则略)

这种结构的数据可以用类似下面这样的逻辑来控制执行流程:

switch (data.purpose) {
case "hello":
return await RunHelloAction(data);
// 或使用类来聚合数据和逻辑:
return await new HelloAction(data).run();
case "jira":
return await RunJiraAction(data);
default:
throw new Error("unsupported purpose");
}

这种设计看起来比较符合面向对象的思想:

  • 通过定义一个“基类”(公共字段)来抽象“子类”(功能模块)的共同特征,暴露统一的对外接口来接收 LLM 的返回数据,将具体实现隐藏在内部。
  • 在“工厂函数”中,根据“特征值”构建“子类”的实例。
  • “子类”负责决定如何使用 LLM 的返回数据来执行具体操作。

这种一次性完成与 LLM 的所有交互的方式能够有效降低机器人的响应时间,提供更好的用户体验。

流程图

Round 2

上面的思路 1 已经能比较好的完成我们的需求,程序结构也比较清晰。但随着不断加入新功能,一次性要传入的 instruction 会越来越多,提示词也要添加更多的限制条件和定语。同时,为了满足“子类”越来越复杂的细分需求 ,可能 还要在顶层的抽象结构中引入更多字段。这些问题会导致代码的维护难度逐渐增加。

例如:

  • 如果我们只希望机器人和我们打招呼,那传入 Jira 建卡相关的 instruction 就会浪费 token 额度,因为我们的操作和 Jira 完全没关系。

  • 如果我们希望机器人提供使用帮助,那么 LLM 只需要其他子命令的简单说明,而不需要提供控制它们输出数据的 instruction 和上下文数据。

  • 有时单纯用一个 purpose 字段来识别操作并不够用。 比如当 purposehelp 时,我们可能还想得知用户是否在查询某个特定功能的帮助信息。

    理想情况下,我们只应该向 LLM 输入目标功能自身的使用说明文档,而不必提供其他无关命令的信息。 这能有效控制 LLM 生成内容的范围,避免包含太多上下文而降低结果的准确性。

一种改善方法是在消息中识别出关键字,然后对 instruction 进行动态加载和拼接后再发给 LLM。

但正如前文所说的,手搓关键字匹配十分困难,在自然语言的使用场景 下也无法完全控制用户的输入。既然如此,我们完全可以把这个任务交给 LLM,让擅长的“人”做擅长的事。

我们将整个流程分为两步:

[Pass 1]

把消息完整发送给 LLM,初步分析用户的意图,得到简单的“意图”数据结构,用于确定后续的处理分支。

[Pass 2]

根据初始意图调用对应的功能模块。功能模块会根据自身需要,加载对应的 instruction 或其他补充信息,连同原始消息一起再次交给 LLM 处理, 最终得到功能模块所需的完整数据结构。

Purposes Instruction

在思路 1 的基础上,将 instruction 的前置公用部分分离出来,我们就得到了意图分析的 instruction。为了更准确地识别后续要执行的操作,我将意图分析结果分成主要和次要两类。这样可以避免出现将“如何创建 Jira 卡”的帮助请求,错误识别成“创建 Jira 卡”的情况。

# 目标
分析输入信息,从中提取所有“意图”信息,并返回如下数据结构
{
primary: string;
secondary: string[];
}
对信息中表达的意图进行分类:
- primary: 该消息最希望达成的主要请求
- secondary: 消息中其他与目的相关的关键词,按其与 primary 的相关性,从强到弱排列
(示例略)
## 支持的意图
- `help`:当寻求帮助或询问问题时
- `jira`:当尝试进行 jira 相关的操作时
- `hello`:当向机器人打招呼时
- `unknown`:当上述情况都不匹配时

流程图

后续改进的可能性

完成思路 2 的开发后,我才意识到“初始意图分析”的处理不就是 LLM function calling 做的事吗?上面的设计等于是重新做了一个简化的、不依赖 LLM 实现的 function calling 框架。我们完全可以利用 function calling(或 MCP)来让 LLM 自己识别和调用 Pass 2 中的功能模块。

但从另一个角度讲,我们自己实现的方案相比 LLM function calling 有如下优势:

  • 不需要 LLM 支持 function calling,这扩大了我们可以使用的模型集。
  • 通过显式识别意图,避免了对 LLM 的滥用,可以更好的限制机器人的行为,避免提供和使用场景无关的信息。例如,如果我们和机器人说“给我冲一杯咖啡”,LLM 会因为没有注册对应 tool 而不会执行 function call,因此可能会返回一个通用的回应;而在我们的实现中,LLM 会直接返回 unknown 意图,让机器人可以返回一个“不支持”的错误信息。