支持 Laravel.io 的持续开发 →

Using Laravel to interact with OpenAI's Assistants API (with Vision)

10 Feb, 2024 12 min read

几个月前,Open AI发布了其助理API,该API在撰写本文时仍在beta测试中。简单来说,助理是一种新的、(希望)更好的方式来利用OpenAI模型。在此之前,使用Chat Completions API时,你必须保存用户和模型之间交换的每个消息的历史,然后每次将整个历史发送回API,因为Chat Completions API不是有状态的。

于是有了助理。助理是有状态的,会为你保存历史,因此你可以轻松地只发送最后一条用户消息,并接收回一个答案。除函数调用外,还包括像代码解释器和知识检索等酷炫工具。

<h2 class="subheading">入门指南</h2>

现在,要使用Laravel与OpenAI API交互,我们将使用由Nuno Maduro和Sandro Gehri开发的已经受欢迎的包:<a title="OpenAI PHP客户端" href="https://github.com/openai-php/client" target="_blank" rel="noopener">https://github.com/openai-php/client</a>以及该包的Laravel特定包装器:<a title="OpenAI Laravel客户端" href="https://github.com/openai-php/laravel" target="_blank" rel="noopener">https://github.com/openai-php/laravel</a>。

首先,简单地引入Laravel包装器包

composer require openai-php/laravel

这将安装所需的依赖项,包括OpenAI PHP客户端包。之后,执行安装命令

php artisan openai:install

接下来,你应在.env文件中添加你的OpenAI API密钥

OPENAI_API_KEY=sk-...
OPENAI_ORGANIZATION=org-...

在你的API密钥部分找到或创建你的API密钥

<a title="Api keys" href="https://geoligard.com/storage/posts/December2023/api-keys.png" target="_blank"><img title="api keys" src="https://geoligard.com/storage/posts/December2023/api-keys.png" alt="api keys" width="100%" height="auto"></a>

现在我们已经准备好使用这个包与API进行交互。但在那之前,让我们创建一个助手。请注意,助手也可以通过API创建,但为了简化速度,我们将使用OpenAI的游乐场来创建助手。请转到:<a title="OpenAI playground" href="https://platform.openai.com/playground" target="_blank" rel="noopener">https://platform.openai.com/playground</a> 并创建一个助手。您可以给他任何名字,输入任何您可能有的说明,选择一个模型,并可以启用或不启用额外的工具。当您保存更改时,请确保复制助手的ID,我们稍后需要它。

<a title="Create Assistant" href="https://geoligard.com/storage/posts/December2023/create-assistant.png" target="_blank"><img title="Create Assistant" src="https://geoligard.com/storage/posts/December2023/create-assistant.png" alt="Create Assistant" width="100%" height="auto"></a>

好的,现在,设想我们正在开发一个应用程序,其中我们在后端负责。用户将在前端输入一条聊天消息,通常是我们的助手需要回答的问题,前端将使用我们的应用程序的内部API,并将用户输入的消息发送给我们。在后台,我们将向助手发出请求并等待他的响应。在收到响应后,我们将将其返回到前端显示给用户。本文将专注于过程的客户端部分。我提到,理论上某些前端将调用我们的后端API,但我们可以像使用Postman一样模拟这个过程,我们将在以后实际做。

让我们只创建一条路由和一个 AssistantController.php 来执行我们的逻辑。

php artisan make:controller AssistantController
// api.php

Route::post('/assistant', [AssistantController::class, 'generateAssistantsResponse']);
<h2 class="subheading">创建和运行线程</h2>

在我们创建主函数 generateAssistantsResponse 之前,让我们创建几个辅助函数。按照助手的流程,我们需要使用助手的ID创建一个 thread(这是一个在用户和助手之间交换信息的类型容器),然后我们需要提交一个实际的用户的 message,并触发一个 run。当运行成功完成后,我们应得到响应。

// AssistantController.php

use OpenAI\Laravel\Facades\OpenAI;

class AssistantController extends Controller
{
    private function submitMessage($assistantId, $threadId, $userMessage)
    {
        $message = OpenAI::threads()->messages()->create($threadId, [
            'role' => 'user',
            'content' => $userMessage,
        ]);

        $run = OpenAI::threads()->runs()->create(
            threadId: $threadId,
            parameters: [
                'assistant_id' => $assistantId,
            ],
        );

        return [
            $message,
            $run
        ];
    }

    private function createThreadAndRun($assistantId, $userMessage) 
    {
        $thread = OpenAI::threads()->create([]);

        [$message, $run] = $this->submitMessage($assistantId, $thread->id, $userMessage);

        return [
            $thread,
            $message,
            $run
        ];
    }

    private function waitOnRun($run, $threadId)
    {
        while ($run->status == "queued" || $run->status == "in_progress")
        {
            $run = OpenAI::threads()->runs()->retrieve(
                threadId: $threadId,
                runId: $run->id,
            );

            sleep(1);
        }

        return $run;
    }

    private function getMessages($threadId, $order = 'asc', $messageId = null)
    {
        $params = [
            'order' => $order,
            'limit' => 10
        ];

        if($messageId) {
            $params['after'] = $messageId;
        }

        return OpenAI::threads()->messages()->list($threadId, $params);
    }
}

简而言之,我们将使用 createThreadAndRun 函数创建一个线程、向线程提交消息并创建一个运行。我们使用的包中有一个等价的 createAndRun 函数,但这个我们创建的函数可能有助于您更好地理解正在发生的事情。然后我们将使用 waitOnRun 函数等待运行完成,最后我们可以使用 getMessages 函数从助手那里获取响应。

<h2 class="subheading">整合各个部分</h2>

现在,我们可以创建主函数 generateAssistantsResponse,该函数定义在我们之前创建的 assistant 路由中。

public function generateAssistantsResponse(Request $request)
{
    // hard coded assistant id
    $assistantId = 'asst_someId'; 
    $userMessage = $request->message;

    // create new thread and run it
    [$thread, $message, $run] = $this->createThreadAndRun($assistantId, $userMessage); 

    // wait for the run to finish
    $run = $this->waitOnRun($run, $thread->id); 

    if($run->status == 'completed') {
        // get the latest messages after user's message
        $messages = $this->getMessages($run->threadId, 'asc', $message->id); 

        $messagesData = $messages->data;

        if (!empty($messagesData)) {
            $messagesCount = count($messagesData);
            $assistantResponseMessage = '';
            
            // check if assistant sent more than 1 message
            if ($messagesCount > 1) { 
                foreach ($messagesData as $message) {
                    // concatenate multiple messages
                    $assistantResponseMessage .= $message->content[0]->text->value . "\n\n"; 
                }

                // remove the last new line
                $assistantResponseMessage = rtrim($assistantResponseMessage); 
            } else {
                // take the first message
                $assistantResponseMessage = $messagesData[0]->content[0]->text->value;
            }

            return response()->json([
                "assistant_response" => $assistantResponseMessage,
            ]);
        } else {
            \Log::error('Something went wrong; assistant didn\'t respond');
        }
    } else {
        \Log::error('Something went wrong; assistant run wasn\'t completed successfully');
    }
}

所以,我们从 request 中获取用户的 message,并使用这些数据以及助手的ID来生成一个新的线程、提交消息并开始运行。我们从 createThreadAndRun 函数中获取所有必要的数据,这我们可以在过程中进一步使用。运行开始后,我们将定期检查运行是否完成。

几秒钟后,运行的状态将设置为 completed。一旦发生,我们将获取用户发送的消息之后的最新消息。由于(根据OpenAI文档)助手有时可能会发送多于一条消息(尚未发生在我身上,但以防万一),我们将检查发送的消息数量并将它们连接成一个单条响应,以简化处理。否则,如果只有一条消息,我们就获取第一条消息。最后,助手的消息以JSON响应的形式返回。

<h2 class="subheading">使用助手函数将图片发送到Vision API</h2>

注意:在我们开始使用视觉API之前,请记住,虽然您可以使用聊天和助手API与GPT-3模型一起使用,但是要使用视觉,您将需要访问GPT-4模型,并且您需要花费1美元才能解锁对GPT-4模型的访问权限:<a title="访问GPT-4" href="https://help.openai.com/en/articles/7102672-how-can-i-access-gpt-4" target="_blank" rel="noopener">https://help.openai.com/en/articles/7102672-how-can-i-access-gpt-4</a>。

好的,我们现在可以讨论助手函数以及我们如何利用它们将不同的数据(第三方或我们自己的应用程序)传递给助手。使用视觉API,例如,您可以允许您的用户上传一张图片,然后GPT-4模型将能够接收这张图片并回答有关它的不同问题。但现在的问题是视觉API尚不支持助手API。如果您尝试上传图片并发送给助手,它将无法描述图片的内容。但是有一个解决方案。

进入函数调用。使用助手函数,我们能够指示助手调用一个特定函数,该函数将转而调用另一个API - 聊天完成API:<a title="聊天API" href="https://platform.openai.com/docs/api-reference/chat/create" target="_blank" rel="noopener">https://platform.openai.com/docs/api-reference/chat/create</a>,它提供视觉API。稍后我们将从聊天API中获取这些数据,以便我们的助手可以相应地做出回应。

回到我们的AssistantController.php,让我们从另一个辅助函数开始。我们将创建一个processRunFunctions方法,该方法检查我们的助手run是否需要执行任何操作,在本例中检查是否需要调用任何函数。如果助手决定调用describe_image函数,我们将启动聊天API并要求它根据消息和用户已经提供的内容和图片提供图片描述。然后我们将视觉相关数据提交回助手并等待该操作的完成。请注意,我们正在增加max_tokens的默认值,以便响应不会中断。

private function processRunFunctions($run)
{
    // check if the run requires any action
    while ($run->status == 'requires_action' && $run->requiredAction->type == 'submit_tool_outputs')
    {
        // Extract tool calls
        // multiple calls possible
        $toolCalls = $run->requiredAction->submitToolOutputs->toolCalls; 
        $toolOutputs = [];

        foreach ($toolCalls as $toolCall) {
            $name = $toolCall->function->name;
            $arguments = json_decode($toolCall->function->arguments);

            if ($name == 'describe_image') {
                $visionResponse = OpenAI::chat()->create([
                    'model' => 'gpt-4-vision-preview',
                    'messages' => [
                        [
                            'role' => 'user',
                            'content' => [
                                [
                                    "type" => "text",
                                    "text" => $arguments?->user_message
                                ],
                                [
                                    "type" => "image_url",
                                    "image_url" => [
                                        "url" => $arguments?->image,
                                    ],
                                ],
                            ]
                        ],
                    ],
                    'max_tokens' => 2048
                ]);

                // you get 1 choice by default
                $toolOutputs[] = [
                    'tool_call_id' => $toolCall->id,
                    'output' => $visionResponse?->choices[0]?->message?->content
                ];
            }
        }

        $run = OpenAI::threads()->runs()->submitToolOutputs(
            threadId: $run->threadId,
            runId: $run->id,
            parameters: [
                'tool_outputs' => $toolOutputs,
            ]
        );

        $run = $this->waitOnRun($run, $run->threadId);
    }

    return $run;
}

现在我们可以修改我们的主方法generateAssistantsResponse以处理用户上传的图片,并处理助手可能调用的任何函数。我不会详细介绍如何验证和保存用户提供的图片,但请记住,图片可以作为URL或base64发送给视觉API,但URL是首选的。

public function generateAssistantsResponse(Request $request)
{
    $assistantId = 'asst_someId';
    $userMessage = $request->message;

    // check if user uploaded a file
    if($request->file('image')) {
        // validate image

        // store the image and get the url - url hard coded for this example
        $imageURL = 'https://images.unsplash.com/photo-1575936123452-b67c3203c357';

        // concatenate the URL to the message to simplify things
        $userMessage = $userMessage . ' ' . $imageURL;
    }

    [$thread, $message, $run] = $this->createThreadAndRun($assistantId, $userMessage);

    $run = $this->waitOnRun($run, $thread->id);

    // check if any assistant functions should be processed
    $run = $this->processRunFunctions($run);

    if($run->status == 'completed') {
        $messages = $this->getMessages($run->threadId, 'asc', $message->id);

接下来要做的就是添加一个函数定义。您可以通过API添加函数定义,但由于示例的简洁性,我们将通过Playground添加函数定义。以下是我们要使用的定义

{
  "name": "describe_image",
  "description": "Describe an image user uploaded, understand the art style, and use it as an inspiration for constructing an appropriate prompt that will be later used to generate a skybox.",
  "parameters": {
    "type": "object",
    "properties": {
      "user_message": {
        "type": "string",
        "description": "Message the user sent along with the image, without the URL itself"
      },
      "image": {
        "type": "string",
        "description": "URL of the image user provided"
      }
    },
    "required": [
      "user_message",
      "image"
    ]
  }
}

以下是在Playground中的样子。

<a title="添加函数" href="https://geoligard.com/storage/posts/December2023/add-function.png" target="_blank"><img title="添加函数" src="https://geoligard.com/storage/posts/December2023/add-function.png" alt="添加函数" width="100%" height="auto"></a>

<a title="描述图片函数" href="https://geoligard.com/storage/posts/December2023/describe-image-function.png" target="_blank"><img title="描述图片函数" src="https://geoligard.com/storage/posts/December2023/describe-image-function.png" alt="描述图片函数" width="100%" height="auto"></a>

下面是在Postman中整个API请求/响应流的样子。

<a title="Postman示例" href="https://geoligard.com/storage/posts/December2023/postman-example.png" target="_blank"><img title="Postman示例" src="https://geoligard.com/storage/posts/December2023/postman-example.png" alt="Postman示例" width="100%" height="auto"></a>

如果您想从AssistantController.php复制全部代码,请前往 <a title="Gist示例" href="https://gist.github.com/goran-popovic/b104e2cc3c16db9312cd1e90d4e5e256" target="_blank" rel="noopener">Gist</a>。

<h2 class="subheading">结论</h2>

总结来说,请记住这是一个基本的示例,仅用于展示一些可能性,并且代码可以通过很多方式进行改进。例如,你可能不应该硬编码你的助手ID,而是可以将其保存在你的数据库模型中。目前,唯一检查运行是否已完成的方法是定期检查,这远远不够理想,但在文档中提到这将在未来改变:[OpenAI 助理 API 限制](https://platform.openai.com/docs/assistants/how-it-works/limitations)。你不必等待单个请求完成运行,可以将代码的部分调度到 Laravel 任务中并异步执行。任务完成后,可以通过 Websockets 广播一个事件,并将助手响应发送到前端的通知,或者你可以使用 Webhooks 将结果发送给其他用户。

此外,就像我之前提到的,助手 API 目前处于测试版,并且在未来几周或几个月内可能会发生变化。

这篇文章到此结束,希望它为你使用助手 API 提供了一个良好的起点。

祝您快乐构建!

最后更新于5个月前。

driesvints 赞了这个文章

1
喜欢这篇文章吗?让作者知道并为他们鼓掌!
geoligard (Goran Popović) 软件开发人员,喜欢使用 Laravel,在geoligard.com上写博客。

你可能还会喜欢以下文章

2024年3月11日

如何使用 Larastan 将您的 Laravel 应用程序从 0 构建到 9

在 Laravel 应用程序运行之前发现错误是可能的,多亏了 Larastan,它...

阅读文章
2024年7月19日

不使用 trait 标准化 API 响应

我注意到大多数用于 API 响应的库都是使用 trait 实现的,并且...

阅读文章
2024年7月17日

在您的Laravel项目中通过Discord通知收集反馈

如何在Laravel项目中创建反馈模块并在收到消息时收到Discord通知...

阅读文章

我们感谢以下这些 极佳的公司 对我们的支持

您的标志在此处?

Laravel.io

Laravel问题解决、知识分享和社区建设的门户。

© 2024 Laravel.io - 版权所有。