需求说明
因为日常工作建 Jira 卡太麻烦,要选一堆字段,而且还要自己写上下文、AC 等内容。为了减轻自己和团队的工作量,我想创建一个 Slack 机器人,能够简单的通过发消息来完成建卡自动化。
同时为了方便使用,这个机器人要满足下面的需求:
- 和用户打招呼
- 提供使用帮助,包括机器人能够处理什么样的命令,每个命令的用法等
- 能够根据 thread 上下文快速 jira 建卡
- 后续功能扩展
虽说初衷是为处理 Slack 消息开发的,但本文并不会讨论 Slack 集成的相关内容。这里只是记录一下设计和开发思路,因此也适用于其他通过自然语言输入来完成 AI 交互的开发场景。
效果展示
机器人最终执行效果如下:
发送消息后返回建卡数据
我给后台代码添加了 Dry run 模式, 只输出创建 Jira 卡的参数, 避免在测试过程中真的去建一堆不需要的卡片.
流程设计
我们要处理的是自然语言的输入(NLP)。比较明确的流程思路是:
- 提取自然语言输入消息中的信息
- 将信息进行结构化转换,便于程序处理
- 根据结构化数据,调用对应的功能处理模块
- 在功能模块中对数据进行必要的整理、转换等操作,如果有必要,再进一步交给外部服务处理,比如调用 Jira API 来创建 Ticket。
- 根据处理结果,生成返回的消息或数据,作为操作反馈发回给调用方。
得益于 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
字段来识别操作并不够用。 比如当purpose
是help
时,我们可能还想得知用户是否在查询某个特定功能的帮助信息。理想情况下,我们只应该向 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
意图,让机器人可以返回一个“不支持”的错误信息。