从零构建ChatGPT插件连接器:意图识别与API调用实战
1. 项目概述:从零构建一个ChatGPT插件连接器
最近几周,我一直在ChatGPT的插件等待名单上排队。等待的过程让我萌生了一个想法:与其被动等待,不如自己动手,尝试将一个外部应用连接到ChatGPT上。市面上当然有现成的强大工具,比如Langchain,它能做这件事,甚至功能更丰富。类似的框架或项目肯定也不在少数。但我的目的很单纯,就是想抛开这些“轮子”,亲手从最基础的原理开始,把东西连起来,看看整个流程究竟是如何运作的,中间会遇到哪些坑。这就像学开车,直接开自动挡当然快,但手动挡能让你更懂车。最终,我成功实现了一个简易但核心流程完整的“DIY ChatGPT插件连接器”。如果你也对大语言模型(LLM)如何与外部世界交互感兴趣,或者想为自己的服务增加一个智能的“自然语言接口”,那么这篇记录或许能给你一些直接的参考。
简单来说,ChatGPT插件是一种让外部服务与ChatGPT的语言模型进行交互的机制。用户可以用自然语言下达指令,比如“帮我查看并回复最新的三封工作邮件”,ChatGPT在理解意图后,就能通过对应的插件调用真实的邮件API来完成操作。我的目标就是模拟这个机制的核心部分:让一个自建的LLM(为了简化,我使用了OpenAI的API作为“大脑”)能够识别用户意图、找到对应的外部API、并代为执行调用。
整个项目的核心挑战在于“意图识别”和“API调用生成”这两个环节。这不仅仅是简单的字符串匹配,而是需要让LLM根据对插件功能的描述和API文档的理解,动态地做出决策。下面,我就来拆解我的实现思路、具体步骤,以及过程中那些值得分享的“踩坑”经验。
2. 核心思路与架构设计
在开始写代码之前,我们需要先厘清ChatGPT官方插件的工作机制。虽然OpenAI没有公开其内部架构的详细文档,但他们提供了如何构建一个兼容插件的Web应用的规范。这足以让我们反向推导出一个可行的自制方案。
2.1 基本原理拆解
首先,我们要理解几个关键角色:
- 大型语言模型(LLM):项目的“大脑”。它负责理解用户的自然语言输入。你可以把它想象成一个极其强大的文本预测引擎,根据给定的上下文(提示词)生成最合理的后续文本。在本项目中,我使用OpenAI的GPT-3.5/4 API来扮演这个角色。
- 插件(外部服务):提供具体功能的Web服务,通过REST API暴露其能力。例如,一个待办事项服务可能有
GET /todos(获取列表)和POST /todos(创建事项)等端点。 - 连接器(我们的核心):这是我们要构建的中枢系统。它需要完成以下工作:
- 插件注册与管理:接收外部服务的“名片”(即
ai-plugin.json清单文件和OpenAPI规范),理解该插件能做什么,并将其信息存储起来。 - 意图路由:当用户输入一句话时,判断这句话的意图是否与某个已注册插件的功能相关。
- API调用构造与执行:如果意图匹配,则根据用户输入和API文档,动态生成一个具体的HTTP请求(包括URL、方法、参数等),然后代表用户去执行这个请求。
- 响应处理与整合:获取API的返回结果后,要么直接返回给用户,要么再次交给LLM,让它用更自然、更整合的方式回复用户。
- 插件注册与管理:接收外部服务的“名片”(即
2.2 自制连接器的架构设计
基于以上理解,我设计了一个简单的三层架构:
用户输入 -> [连接器] -> 最终回复 | [1. 意图识别层] --(匹配)--> [2. API调用层] --(执行)--> [3. 响应处理层] | | | (查询插件注册表) (读取对应API文档) (策略:直接返回或LLM润色)第一层:插件注册与持久化这是所有操作的基础。每个插件都需要提供一个标准的ai-plugin.json文件,其中有一个关键字段叫description_for_model,这是用自然语言向LLM描述插件功能的“自我介绍”。我们的连接器在启动或接收到注册请求时,会读取这个文件以及配套的OpenAPI文档(Swagger规范),将插件的唯一标识、名称、功能描述和API文档摘要存储起来。为了简化,我初期使用了内存字典来存储,但生产环境显然需要数据库。
注意:
description_for_model字段的撰写质量至关重要。它应该清晰、简洁地概括插件的核心能力,避免歧义。例如,“管理用户的待办事项清单,可以创建、查看、更新和删除事项”就比“处理任务”要好得多。
第二层:动态意图识别与路由这是最有趣也最具挑战的部分。当用户说“提醒我明天下午三点开会”时,系统如何知道这句话应该由“提醒插件”来处理,而不是“邮件插件”或“天气插件”?
我的方案是:将意图识别本身也作为一个LLM任务。具体做法是:
- 将所有已注册插件的
description_for_model整理成一个列表。 - 将用户输入和这个列表一起,构造一个特定的提示词(Prompt),提交给LLM。
- 要求LLM从列表中选出最匹配用户意图的插件描述,或者回答“无匹配”。
这个方法的优势在于利用了LLM强大的语义理解能力,即使描述和用户输入的措辞不完全一致(比如“设个提醒” vs “创建提醒事项”),也能正确匹配。关键在于设计一个精准的提示词。
第三层:API调用构造与执行识别出意图和对应插件后,下一步是“代劳”调用API。LLM本身不能发送HTTP请求,但它可以告诉我们如何发送。
我的方法是:将当前用户的问题和该插件的完整OpenAPI文档再次提交给LLM。通过精心设计的提示词,引导LLM根据文档和问题,输出一个结构化的请求对象,包括:
host: API的主机地址(从插件注册信息中获取)。method: HTTP方法,如GET、POST。headers: 需要携带的请求头(如认证信息)。parameters: 请求参数。parameter_location: 参数位置,如query、path、body。
然后,我们的连接器代码会解析这个JSON对象,使用requests库等工具实际发起HTTP调用。
第四层:响应处理策略拿到API的原始响应(通常是JSON)后,如何处理?我采用了两种简单策略:
- 对于
GET类查询请求(如“我的待办事项有哪些?”),将API返回的数据连同原始用户问题,再次发送给LLM,让它总结、提炼成一个通顺的自然语言回复。这能提升用户体验。 - 对于
POST/PUT/DELETE类操作请求(如“创建一条提醒”),通常API会返回操作成功与否的状态。这种情况下,我选择将API的响应直接、简洁地转发给用户,例如“提醒已成功创建”。
3. 关键技术实现细节与踩坑实录
理论清晰后,我们进入实战环节。这里我会详细说明几个关键步骤的具体实现,并分享那些在调试过程中耗费我大量时间的“坑”。
3.1 插件信息的存储与索引
如前所述,我们需要一个地方存放所有插件的“档案”。我定义了一个简单的Plugin类:
class Plugin: def __init__(self, plugin_id, name, description_for_model, api_doc_url, openapi_spec): self.id = plugin_id self.name = name self.description_for_model = description_for_model # 给LLM看的功能描述 self.api_doc_url = api_doc_url self.openapi_spec = openapi_spec # 解析后的OpenAPI字典对象 # 初始化一个“插件仓库”(这里用字典模拟,实际应用请用数据库) plugin_registry = {}注册流程的伪代码如下:
- 从插件提供的标准URL(如
https://api.example.com/.well-known/ai-plugin.json)获取清单文件。 - 解析清单,得到
description_for_model和api.url(OpenAPI文档地址)。 - 从
api.url获取并解析OpenAPI规范(通常是YAML或JSON格式)。 - 将以上信息构建成一个
Plugin对象,存入plugin_registry。
实操心得:解析OpenAPI规范时,建议使用成熟的库如
prance或openapi-spec-validator。它们能帮你处理引用($ref)解析和格式验证,比自己写解析器稳健得多。另外,一定要缓存API文档,避免每次意图识别都去远程拉取,影响速度。
3.2 意图识别提示词的精雕细琢
这是整个项目的“灵魂”之一。一个糟糕的提示词会导致匹配错误,后续所有步骤都会失败。经过多次迭代,我最终确定了以下提示词模板:
intent_recognition_prompt = """ 你是一个智能意图分类器。下面是一个插件功能描述列表,每个描述前有一个编号: {plugin_descriptions_list} 请严格根据用户的输入,判断其意图是否与上述某个插件功能描述匹配。 用户输入:"{user_input}" 请只输出最匹配的那个描述前的编号数字。如果没有任何一个描述匹配用户意图,请输出“no match”。 不要输出任何其他文字、标点或解释。 """如何构造plugin_descriptions_list?我将每个插件的description_for_model字段与它的ID一起格式化,例如:
0: 管理用户的待办事项清单,可以创建、查看、更新和删除事项。 1: 读取和发送电子邮件,并可以管理邮箱中的邮件。 2: 获取当前天气情况、天气预报以及空气质量指数。为什么这个提示词有效?
- 角色设定:明确告诉LLM它现在是“意图分类器”,让它聚焦于分类任务。
- 指令清晰:给出了具体的输入格式和极其严格的输出格式要求(“只输出编号或‘no match’”)。这减少了LLM“自由发挥”的可能,使输出易于程序解析。
- 示例化:虽然没有在提示词中给出例子(Few-Shot),但清晰的列表格式本身就能提供很好的上下文。
踩过的坑:
- 初期提示词过于宽松:我曾用过“请判断用户想使用哪个插件?”这样的提示,LLM经常会回复一段话,如“用户可能想使用待办事项插件,因为...”,这给后续的自动化解析带来了巨大麻烦。
- 描述列表过长:当插件数量很多时,将所有描述塞进提示词可能会超出上下文长度限制,且可能影响识别精度。解决方案是引入嵌入向量(Embeddings)和向量数据库进行初步筛选。先用一个轻量级模型(如
text-embedding-ada-002)将用户输入和所有插件描述转换成向量,通过余弦相似度快速找出Top 3最相关的插件,再将这3个描述的详细内容送入LLM做精确判断。这大大提升了效率和准确性。
3.3 API调用生成的提示词设计
这是另一个核心挑战。我们需要LLM扮演一个“API调用生成器”的角色。我的最终版提示词如下:
api_call_generation_prompt = """ 当前日期和时间是:{current_datetime} 你是一个资源丰富的个人助理。以下是某个插件的API文档: {openapi_doc_summary} 请根据以下用户问题,并严格参照上述API文档,生成调用对应API所需的请求数据。 请确保你拥有生成请求所需的全部信息。如果用户问题中缺少必要参数(如缺少时间、标题等),请直接要求用户补充,不要尝试生成不完整的请求。 请将生成的请求数据以如下严格的JSON格式输出: {{ "host": "api.example.com", // 从插件信息中获取,这里仅为示例 "method": "GET", "headers": {{"Authorization": "Bearer xxx"}}, "parameters": {{"task_id": 123}}, "parameter_location": "query" // 可选值: "query", "path", "body", "header" }} 用户问题:{user_question} """关键点解析:
- 注入当前时间:
{current_datetime}这个变量至关重要!LLM的训练数据是有截止日期的,它不知道“现在”是什么时候。对于时间敏感的插件(如提醒、日历),用户说“下周一”或“三小时后”,我们必须将准确的当前时间告诉LLM,它才能计算出具体的时间点。这是我早期调试提醒功能时最大的一个发现。 - 提供文档摘要:直接将完整的OpenAPI文档塞进去可能太长。我写了一个函数,从
openapi_spec中提取每个path和method下的summary、description以及parameters的name和description,拼接成一个精简版的文档摘要。这既提供了足够信息,又节省了tokens。 - 严格的输出格式:要求输出一个结构化的JSON。键名(
host,method等)是固定的,方便后续代码解析。parameter_location用来指示参数应该放在URL查询字符串、请求路径、请求体还是请求头中。 - 完整性检查:提示词中明确要求LLM先检查信息是否充足。如果用户说“设个提醒”但没说什么时间,LLM应该回复“请提供提醒的具体时间”,而不是生成一个缺少
due_time参数的无效请求。
一个具体的例子:
- 用户输入:“查看我所有未完成的待办事项。”
- 插件API文档摘要显示有一个
GET /todos端点,有一个可选查询参数status。 - LLM生成的请求JSON可能如下:
随后,我的连接器代码会将其转换为实际的HTTP请求:{ "host": "todo-api.internal.com", "method": "GET", "headers": {"Authorization": "Bearer user_token_here"}, "parameters": {"status": "pending"}, "parameter_location": "query" }GET https://todo-api.internal.com/todos?status=pending。
3.4 请求执行与响应处理
拿到LLM生成的请求规范后,执行就相对直接了。我使用Python的requests库:
import requests import json def execute_api_call(request_spec, plugin): # 构建完整的URL base_url = f"https://{request_spec['host']}" # 这里需要根据plugin.openapi_spec中的servers或paths信息,以及parameter_location来拼接最终URL # 这是一个简化的示例 full_url = base_url + "/todos" # 实际中需要根据endpoint动态生成 params, data, json_data = None, None, None if request_spec['parameter_location'] == 'query': params = request_spec.get('parameters', {}) elif request_spec['parameter_location'] == 'body': # 假设是JSON body json_data = request_spec.get('parameters', {}) headers = request_spec.get('headers', {}) # 通常需要注入认证头,可以从插件或用户会话中获取 headers.update({"Authorization": f"Bearer {get_user_token()}"}) response = requests.request( method=request_spec['method'], url=full_url, params=params, json=json_data, headers=headers, timeout=30 ) response.raise_for_status() # 抛出HTTP错误 return response.json()响应处理策略的细化: 我最初简单的GET走LLM、POST直接返回的策略在实践中显得过于粗糙。更好的做法是根据API响应的内容类型和用户意图来决定:
- 数据查询类(如获取列表、详情):无论HTTP方法,只要返回的是数据,都交给LLM进行总结、格式化或翻译成自然语言。例如,API返回一长串JSON格式的天气预报数据,LLM可以将其转化为“北京今天晴,最高气温25度,南风3级,空气质量良”这样的句子。
- 操作确认类(如创建、更新、删除):API通常返回操作结果(
{"success": true, "id": 123})。对于这类响应,可以直接提取关键信息(如“创建成功,ID:123”)返回给用户,或者让LLM生成一个更友好的确认消息(“好的,我已经为您创建了新的待办事项。”)。 - 错误处理:如果API调用失败(4xx, 5xx),不能直接把错误堆栈扔给用户。应该捕获异常,将错误信息(如“权限不足”或“资源未找到”)作为上下文,让LLM生成一个友好的错误提示,或者引导用户进行正确操作。
4. 系统集成与完整工作流演示
让我们通过一个完整的端到端例子,把上述所有环节串联起来。假设我们已经注册了一个“智能家居控制”插件,其description_for_model是:“控制家中的智能设备,包括开关灯、调节恒温器、查看摄像头状态。”
4.1 工作流步骤分解
步骤一:用户输入用户说:“把客厅的灯打开。”
步骤二:意图识别
- 连接器从
plugin_registry中获取所有插件的描述列表。 - 构造提示词:“你是一个智能意图分类器...列表:[0: 控制家中的智能设备...]...用户输入:‘把客厅的灯打开。’”
- 调用LLM(如
gpt-3.5-turbo)。LLM返回:“0”。 - 连接器解析出数字0,对应“智能家居控制”插件。
步骤三:API调用生成
- 连接器加载插件0的OpenAPI文档摘要。摘要中描述了
POST /devices/{device_id}/control端点,需要参数action(枚举:on, off)和device_id。 - 构造提示词:“当前日期和时间是:2023-10-27 20:00:00...API文档:[智能家居API摘要]...用户问题:‘把客厅的灯打开。’”
- 调用LLM。LLM返回一个JSON对象:
注意:这里LLM需要知道“客厅的灯”对应的{ "host": "smart-home-api.example.com", "method": "POST", "headers": {}, "parameters": {"device_id": "living_room_light", "action": "on"}, "parameter_location": "body" }device_id是living_room_light。这可以通过在插件注册时额外提供一个“设备名称到ID的映射表”,或者在提示词中注入这个信息来实现。这是一个需要根据业务扩展的点。
步骤四:请求执行
- 连接器代码解析上述JSON。
- 补充必要的认证头(如从用户会话获取的OAuth Token)。
- 向
https://smart-home-api.example.com/devices/living_room_light/control发送POST请求,Body为{"action": "on"}。 - 假设API返回:
{"status": "success", "message": "Light turned on"}。
步骤五:响应处理
- 这是一个操作确认类的POST请求,返回了成功状态。
- 连接器可以选择直接将API的
message字段(“Light turned on”)返回给用户。 - 或者,为了更自然,可以将原始用户输入和API响应一起给LLM,让它生成回复:“好的,已经为您打开了客厅的灯。”
最终,用户收到回复:“好的,已经为您打开了客厅的灯。” 整个流程结束。
4.2 性能与优化考量
在原型验证后,需要考虑实际应用的性能问题:
- 延迟:一次用户查询可能涉及2-3次LLM调用(意图识别、API生成、响应润色),加上网络I/O,总延迟可能很高。可以考虑将意图识别和API生成合并到一个更复杂的提示词中,通过一次LLM调用完成,但这会提高提示词设计的复杂度和成本。
- 成本:频繁调用LLM API(尤其是GPT-4)费用不菲。对于意图识别这种相对简单的任务,可以尝试使用更小、更便宜的模型(如
gpt-3.5-turbo甚至专门微调过的开源小模型)。对于API文档摘要,可以预先计算并存储,而不是每次动态生成。 - 错误处理与重试:网络请求可能失败,LLM的输出可能不符合预期格式。代码中必须有完善的异常捕获、日志记录和重试机制。对于格式错误的LLM输出,可以尝试用更严格的提示词重试,或者回退到让用户澄清。
5. 常见问题、局限性与进阶思考
在开发过程中,我遇到了不少典型问题,也意识到这个简易框架的诸多局限性。
5.1 常见问题排查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| LLM在意图识别时总是返回“no match” | 1. 插件功能描述 (description_for_model) 写得太模糊或与用户常用说法不符。2. 提示词指令不够清晰,导致LLM理解偏差。 3. 用户输入过于简短或歧义。 | 1.优化描述:用更具体、包含多种表达方式的自然语言重写描述。例如,“管理待办事项”改为“创建、查看、完成或删除待办任务清单”。 2.改进提示词:在提示词中加入1-2个正确匹配的例子(Few-Shot Learning)。 3.引导用户:当返回“no match”时,让连接器回复“我没理解您的意思,您是想管理任务、发送邮件还是查询天气?”,进行澄清。 |
| LLM生成的API请求JSON格式错误 | 1. LLM没有严格遵守输出格式指令。 2. OpenAPI文档过于复杂,LLM无法正确解析。 3. 用户问题中包含了API文档未覆盖的复杂逻辑。 | 1.强化指令:在提示词中使用“你必须”、“严格遵循”等强约束词,并明确说明“输出必须是有效的JSON”。 2.简化文档:提供给LLM的API摘要要精简,只保留核心路径、方法和参数描述,移除不相关的细节。 3.后处理校验:在代码中解析JSON前,先用 json.loads()尝试解析,如果失败,则触发一个“修复流程”,将错误信息和原始提示再次发送给LLM,要求它修正输出。 |
| 调用外部API时认证失败 | 1. 请求头中缺少或使用了错误的认证令牌。 2. 令牌已过期。 3. 插件服务端的认证方式与预期不符(如OAuth 2.0, API Key等)。 | 1.统一认证管理:建立一个安全的令牌管理模块,为每个用户/插件对存储和刷新令牌。 2.错误反馈:捕获401/403错误,在响应中明确提示用户“需要重新授权”或“权限不足”,并可能触发一个重新授权的链接或流程。 3.适配多种认证:在插件注册元数据中增加 auth_schema字段,连接器根据不同的模式(bearer,api_key,oauth2)来构造请求头。 |
| 处理包含多步或复杂条件的用户请求时失败 | 用户请求如“找出上个月花费超过100元的发票,并邮件发送给财务”,涉及查询(发票)和操作(发邮件)多个步骤,当前单轮交互模型无法处理。 | 1.任务分解:这是当前架构的硬伤。需要引入更高级的“智能体(Agent)”框架,使LLM具备规划能力,能将复杂任务分解为多个子任务(查询发票API -> 过滤结果 -> 调用邮件API),并管理中间状态。 2.引导简化:目前可以处理此类请求的方式是,让连接器回复:“我可以帮您查询发票或发送邮件。您想先进行哪一项操作?”引导用户分步进行。 |
5.2 当前方案的局限性
- 单轮对话限制:目前的架构是“输入-处理-输出”的单轮模式,无法处理需要多轮交互、上下文保持的复杂对话(例如,用户先问“我的日程”,然后说“把第一个会议推迟一小时”)。
- 缺乏状态管理与规划能力:无法处理前述的复杂多步骤任务。这需要引入“ReAct”等模式,让LLM具备“思考-行动-观察”的循环能力。
- 插件发现与组合能力弱:官方插件商店允许ChatGPT动态决定使用哪个或哪几个插件。我们的自制系统在多个插件功能重叠时(比如“记笔记”可能对应多个笔记应用),选择策略比较简单。
- 安全性:直接将用户输入和API文档交给LLM,可能存在提示词注入、间接提示攻击等风险,导致LLM生成恶意请求。需要对用户输入进行一定的清洗和校验。
5.3 可能的改进方向
- 引入对话记忆:使用向量数据库存储对话历史,每次处理新请求时,将相关的历史记录作为上下文提供给LLM,以实现多轮对话。
- 向智能体(Agent)演进:采用LangChain、AutoGPT等框架的思想,赋予连接器“工具使用(Tool Use)”和“规划(Planning)”的能力。让LLM自己决定何时调用哪个插件、如何处理结果、下一步做什么。
- 实现插件动态加载与热注册:设计一个管理接口,允许外部服务在运行时通过API向连接器注册自己,而无需重启服务。
- 增强安全与合规:
- 输入过滤:对用户输入进行敏感词过滤和长度限制。
- 输出审查:对LLM生成的API请求参数进行白名单或格式校验,防止调用非预期的危险操作(如删除所有数据)。
- 权限控制:实现用户级别的插件访问权限控制,不是所有用户都能使用所有插件。
这个DIY项目虽然简陋,但它清晰地揭示了大语言模型与外部工具交互的核心逻辑:提示词工程(Prompt Engineering)作为“胶水”,将自然语言意图“翻译”成结构化的机器指令。通过亲手实现一遍,我对LangChain这类工具底层所解决的问题有了更深的理解。它绝不仅仅是调用几个API那么简单,其中涉及的路由、编排、错误处理和安全性设计,每一个环节都值得深入探索。如果你也尝试构建类似系统,我建议从一个小而具体的插件开始(比如一个查询服务器状态的插件),把单条链路彻底跑通,理解每一个环节的输入输出,然后再逐步增加复杂度和功能。
