Assistants API构建多Agent协作系统
深度技术教程:如何用OpenAI Assistants API设计多智能体交互系统,对Agent应用开发者价值极高。
深度技术教程:如何用OpenAI Assistants API设计多智能体交互系统,对Agent应用开发者价值极高。
Experts.js 是创建和部署 OpenAI Assistants 的最简便方式,并将它们作为 Tools 链接在一起,以创建一个具有扩展内存和高精度关注的专家小组系统。
由 Custom Ink | Tech 提供支持 ❤️
OpenAI 新推出的 Assistants API 设立了新的行业标准,相比广泛采用的 Chat Completions API 有了重大进步。它代表了 AI 智能体可用性和工程师与 LLMs 交互方式的一次飞跃。结合最先进的 GPT-4o mini 模型,Assistants 现在可以在称为 Thread 的受管上下文窗口内,引用附加的文件和图像作为知识来源。与 Custom GPTs 不同,Assistants 支持长达 256,000 字符的指令,集成 128 个工具,并利用创新的 Vector Store API 来对每个 Assistant 上的多达 10,000 个文件进行高效的文件搜索。
Experts.js 旨在通过消除管理 Run 对象的复杂性,并允许 Assistants 作为 Tools 链接在一起,来简化这个新 API 的使用。
import { Assistant, Thread } from "experts";
const thread = await Thread.create();
const assistant = await Assistant.create();
const output = await assistant.ask("Say hello.", thread.id);
console.log(output) // Hello
更重要的是,Experts.js 将 Assistants 引入作为 Tools,从而实现了多 AI 智能体系统的创建。每个 Tool 都是由 LLM 驱动的 Assistant,可以担任专门的角色或代表其父 Assistant 或 Tool 完成复杂的任务。这允许复杂的编排工作流程或编排一系列紧密相连的任务。这里展示的是一个公司 Assistant 的例子,它拥有一个产品目录 Tool,而该 Tool 本身有一个由 LLM 驱动的 Tool 来创建 OpenSearch 查询。
通过 npm 安装。使用非常简单,只需导入三个对象。
npm install experts
Experts.js 同时支持 ES6 import 语法和 CommonJS require 语句。
import { Assistant, Tool, Thread } from "experts";
Assistants —— 代表 AI 智能体的主要对象。
Tools —— 可被其他 Assistants 使用的 Assistant。
Threads —— 为智能体提供的受管上下文窗口。
我们的 Assistant 门面对象的构造函数需要一个名称、描述和指令。第三个参数是一组选项,直接映射到 create assistant 文档中列出的所有请求体选项。Experts.js 中的所有示例为了简化起见都是用 ES6 类编写的。默认模型是 gpt-4o-mini。
class MyAssistant extends Assistant {
constructor() {
super({
name: "My Assistant",
instructions: "...",
model: "gpt-4o-mini",
tools: [{ type: "file_search" }],
temperature: 0.1,
tool_resources: {
file_search: {
vector_store_ids: [process.env.VECTOR_STORE_ID],
},
},
});
}
}
const assistant = await MyAssistant.create();
Experts.js 的异步 Assistant.create() 基础工厂函数是使用相同构造选项创建 Assistant 的简单方式。
const assistant = Assistant.create({
name: "My Assistant",
instructions: "...",
model: "gpt-4o-mini",
});
不带 id 参数创建 Assistants 时,将始终创建一个新 Assistant。有关更多信息,请参见我们的部署部分。
ask() 函数是询问或指示智能体的简单接口。它需要一条消息和一个 Thread 标识符。下面将更详细地介绍 Threads。该消息可以是字符串或原生 OpenAI 消息对象。这是 Experts.js 真正闪耀的地方。你永远不需要直接管理 Run 对象或其 Run Steps。
const output = await assistant.ask("...", threadID)
const output = await assistant.ask({ role: "user", content: "..." }, threadID);
通过构造函数选项对象的 tools 和 tool_resources,支持正常的 OpenAI Tools 和函数调用。Experts.js 也支持将 Assistants 添加为 Tools。有关使用 Assistants 作为 Tools 的更多信息可在下一节中找到。使用 addAssistantTool 函数将 Assistant 添加为 Tool。这必须在 Assistant 的构造函数中 super() 之后进行。
class MainAssistant extends Assistant {
constructor() {
super({
name: "Company Assistant",
instructions: "...",
});
this.addAssistantTool(ProductsTools);
}
}
默认情况下,Experts.js 利用 Assistants 流式事件。这些事件允许你的应用通过 OpenAI 的服务器发送事件接收文本、图像和工具输出。我们利用 openai-node 的流助手,并呈现这些事件以及一些自定义事件,让你的 Assistants 能够访问 Run 的完整生命周期。
const assistant = await MainAssistant.create();
assistant.on("textDelta", (delta, _snapshot) => {
process.stdout.write(delta.value)
});
所有 openai-node 流式事件都通过我们 Assistant 的 on() 函数支持。可用的事件名称有:event、textDelta、textDone、imageFileDone、toolCallDelta、runStepDone、toolCallDone 和 end
OpenAI 的服务器发送事件不支持 async/await。
如果监听器需要以异步方式执行工作,例如重定向工具输出,请考虑使用我们对这些事件的扩展。它们在 Run 完成后按此顺序被调用。可用的异步事件名称有:textDoneAsync、imageFileDoneAsync、runStepDoneAsync、toolCallDoneAsync 和 endAsync。
如果你想在调用 Assistant 的 create() 函数时延迟创建附加资源,请在你的类中实现 beforeInit() 函数。这是一个将在 Assistant 创建前调用的异步方法。
async beforeInit() {
await this.#createFileSearch();
}
同样,可以使用 afterInit() 函数。例如,将新创建的 Assistants 的 ID 写入环境文件。
async afterInit() {
// ...
}
所有 Assistant 事件都接收额外的 Experts.js 元数据参数。包含 Run 流的对象。这允许你使用 openai-node 的辅助函数,例如 currentEvent、finalMessages 等。
assistant.on("endAsync", async (metadata) => {
await metadata.stream.finalMessages();
});
使用 Assistant 作为 Tool 是 Experts.js 框架的中心焦点。Tools 是 Assistant 的子类,并封装了其父对象的接口。通过这种方式,Experts.js Tools 是你的智能体架构中的可重用组件。我们的示例为了简洁起见说明了基本的消息传递模式。你应该充分利用 OpenAI 的所有工具和函数调用功能。
class EchoTool extends Tool {
constructor() {
super({
name: "Echo Tool",
instructions: "Echo the same text back to the user",
parentsTools: [
{
type: "function",
function: {
name: "echo",
description: description,
parameters: {
type: "object",
properties: { message: { type: "string" } },
required: ["message"],
},
},
},
],
});
}
}
你的 Tool 的函数名在其父对象的所有 Tool 名称中必须是唯一的。
因此,Tool 类名很重要,有助于 OpenAI 的模型决定调用哪个 Tool。所以为你的 Tool 类选择一个好名字。例如,ProductsOpenSearchTool 会转换为 products_open_search,并清楚地帮助模型根据 Tool 的描述推断其执行的角色。
Tools 通过 addAssistantTool 函数添加到 Assistant。此函数会将 Tool 添加到 Assistant 的 tools 数组并更新 Assistant 的配置。这必须在 Assistant 的构造函数中 super() 之后进行。
class MainAssistant extends Assistant {
constructor() {
super({
name: "Company Assistant",
instructions: "..."
});
this.addAssistantTool(EchoTool);
}
}
你的 Tool Assistant 响应将自动作为父 Assistant 或 Tool 的输出提交。
默认情况下,Tools 由 LLM 模型驱动,执行与 Assistants 相同的所有生命周期事件、运行等。但是,你可以通过将 llm 选项设置为 false 来创建不使用任何核心 Assistant 功能的 Tool。在这样做时,你必须在 Tool 中实现 ask() 函数。返回值将作为工具的输出提交。
class AnswerTwoTool extends Tool {
constructor() {
super({
// ...
llm: false,
parentsTools: [...],
});
}
async ask(message) {
return ...;
}
}
在复杂工作流中,LLM 支持的 Tool 可以用于将人类或其他 LLM 的指令转换为可执行代码,该代码的结果(而非 LLM 输出)需要提交给 Tool 的父级输出。例如,ProductsOpenSearchTool 可以将消息转换为 OpenSearch 查询,执行查询,并返回结果。子类可以实现 answered() 函数来控制输出。在这种情况下,输出将是 OpenSearch 查询,而 Tool 的输出现在包含该 LLM 生成查询的结果。
async answered(output) {
const args = JSON.parse(output);
return await this.opensearchQuery(args);
}
或者,LLM 支持的 Tool 也可以选择将其自身的 Tool 输出重定向回其父级 Assistant 或 Tool。这样就忽略了 LLM 输出。这也允许 Tool 的所有 Tool 输出都被提交为父级的输出。关于为什么这很重要的更多内容,请参见下面的产品目录示例。
class ProductsTool extends Tool {
constructor() {
super({
// ...
temperature: 0.1,
tools: [{ type: "code_interpreter" }],
outputs: "tools",
parentsTools: [...],
});
this.addAssistantTool(ProductsOpenSearchTool);
this.on("imageFileDoneAsync", this.imageFileDoneAsync.bind(this));
}
}
OpenAI 的 Assistants API 引入了一个名为 Threads 的新资源,消息和文件存储在其中。本质上,Thread 是智能体的受管上下文窗口(内存)。使用 Experts.js 创建新 Thread 就像这样简单:
const thread = await Thread.create();
console.log(thread.id) // thread_abc123
你也可以使用消息、文件或 Tool 资源创建 Thread 来启动对话。我们支持 OpenAI 的 Threads API 参考文档中概述的 Thread 创建请求体。
const thread = await Thread.create({
messages: [
{ role: "user", content: "My name is Ken" },
{ role: "user", content: "Oh, my last name is Collins" },
],
});
const output = await assistant.ask("What is my full name?", thread.id);
console.log(output) // Ken Collins
默认情况下,Experts.js 中的每个 Tool 都有自己的 Thread 和上下文。这避免了潜在的 Thread 锁定问题,该问题会在 Tool 共享仍在等待 Tool 输出的 Assistant 的 Thread 时发生。以下图表说明了 Experts.js 如何代表你管理 Thread 来避免此问题:
所有向你的专家提出的问题都需要一个 Thread ID。对于聊天应用程序,该 ID 将存储在客户端上。例如作为 URL 路径参数。使用 Experts.js,无需其他客户端 ID。当每个 Assistant 调用 LLM 支持的 Tool 时,它将根据需要为该 Tool 查找或创建一个 Thread。Experts.js 使用 OpenAI 的 Thread 元数据为你存储这种父级 -> 子级 Thread 关系。
Run 由 Assistant 的 ask 函数为你进行管理。但是,你仍然可以通过两种方式之一传递在创建 Run 时将使用的选项。
首先,你可以在 Assistant 的构造函数中指定 run_options。这些选项将用于 Assistant 创建的所有 Run。这是使用 tool_choice 选项强制模型使用 Tool 的好方法。
class CarpenterAssistant extends Assistant {
constructor() {
super({
// ...
run_options: {
tool_choice: {
type: "function",
function: { name: "my_tool_name" },
},
},
});
this.addAssistantTool(MyTool);
}
}
或者,你可以将选项对象传递给 ask 方法以用于当前 Run。这是创建单个 Run 选项的好方法。
await assistant.ask("...", "thread_abc123", {
run: {
tool_choice: { type: "function", function: { name: "my_tool_name" } },
additional_instructions: "...",
additional_messages: [...],
},
});
要查看这些及更多代码示例的实际应用,请查看我们的测试套件。
在概览部分,我们展示了一个三层智能体系统,可以回答以下类型的问题。这些示例使用了 Experts.js 框架的大部分或全部功能。
使用 textDelta 事件从 Express 路由流式传输响应的基本示例。
import express from "express";
import { MainAssistant } from "../experts/main.js";
const assistant = await MainAssistant.create();
messagesRouter.post("", async (req, res, next) => {
res.setHeader("Content-Type", "text/plain");
res.setHeader("Transfer-Encoding", "chunked");
assistant.on("textDelta", (delta, _snapshot) => {
res.write(delta.value);
});
await assistant.ask(req.body.message.content, req.body.threadID);
res.end();
});
Assistant API 支持带有图像的消息,使用 image_url 或 image_file 内容类型。由于我们的 ask() 函数支持字符串或原生 OpenAI 消息对象。
const output = await assistant.ask(
{
role: "user",
content: [
{ type: "text", text: "Tell me about this image." },
{ type: "image_file", image_file: { file_id: file.id detail: "high" } },
],
},
threadID
);
使用向量存储进行文件搜索很容易,通过 OpenAI 的接口使用我们的第三个配置选项。你也可以使用我们在高级功能中描述的 beforeInit() 函数按需创建向量存储。
class VectorSearchAssistant extends Assistant {
constructor() {
super({
name: "Vector Search Assistant",
instructions: "...",
tools: [{ type: "file_search" }],
temperature: 0.1,
tool_resources: {
file_search: {
vector_store_ids: [process.env.VECTOR_STORE_ID],
},
},
});
}
}
使用流式传输与事件功能来报告 Token 使用情况,允许你拥有每个智能体的指标。
class MyAssistant extends Assistant {
constructor() {
super({
// ...
});
this.on("runStepDone", this.#reportUsage.bind(this));
}
#reportUsage(runStep) {
if (!runStep?.usage?.total_tokens) return;
const iT = runStep.usage.prompt_tokens;
const oT = runStep.usage.completion_tokens;
const tT = runStep.usage.total_tokens;
console.log({ InTokens: iT, OutTokens: oT, TotalTokens: tT });
}
}
为了将 Assistant 部署到生产环境,我们建议以下配置。首先,创建或找到你的 Assistant 的 id。该字符串的格式为 asst_abc123。然后将此 id 传递给 Assistant 或 Tool 的构造函数。这将确保在所有部署中使用相同的 Assistant。
class MyAssistant extends Assistant {
constructor() {
super({
// ...
id: process.env.MY_ASSISTANT_ID
});
}
}
一旦通过 id 找到 Assistant 或 Tool,任何存在的不同的远程配置都会被本地配置覆盖。如果需要,例如在临时环境中,你可以通过将 skipUpdate 选项设置为 true 来绕过此行为。
你可以使用 EXPERTS_DEFAULT_MODEL 环境变量为所有 Assistant 全局设置模型。这仅在你未在 Assistant 构造函数中显式设置模型时才有效。
要调试你的 Assistant,你可以设置 DEBUG=1 环境变量。这将输出所有 API 调用和服务器发送事件的详细日志。Delta 事件可能会相当冗长,默认情况下处于禁用状态。请同时使用 DEBUG_DELTAS=1 环境变量来启用它们。
此项目利用开发容器,意味着你可以在任何支持的 IDE 中打开它以立即开始使用。这包括使用 VS Code 与开发容器,这是推荐的方法。
在开发容器中打开后,创建一个 .env.development.local 文件,其中包含你的 OpenAI API 密钥和 postimage.org API 密钥:
OPENAI_API_KEY=sk-...
POST_IMAGES_API_KEY=...
现在你可以运行以下命令:
./bin/setup
./bin/test