实现 函数调用

This commit is contained in:
Twilight 2024-07-25 01:16:41 +08:00
parent 97753a264a
commit ab03c27304
32 changed files with 728 additions and 61 deletions

View File

@ -0,0 +1,76 @@
<?php
namespace App\Console\Commands;
use App\LLM\Qwen;
use App\Models\Assistant;
use App\Models\Tool;
use App\Repositories\LLM\AIMessage;
use App\Repositories\LLM\ChatEnum;
use App\Repositories\LLM\History;
use App\Repositories\LLM\HumanMessage;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Console\Command;
class TestLLM extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:testllm';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
* @throws GuzzleException
*/
public function handle()
{
$llm = new Qwen();
$history = new History();
$tool = Tool::get();
$llm->setTools($tool);
$llm->setHistory($history);
while (true) {
// var_dump($history->getMessages());
$q = $this->ask('请输入问题');
if (empty($q)) {
$q = "北京天气";
}
$history->addMessage(new HumanMessage($q));
$s = $llm->streamResponse();
// 循环输出
foreach ($s as $item) {
if ($item->role == ChatEnum::Tool) {
if ($item->processing) {
$this->info("正在执行: " . $item->content);
echo "\n";
} else {
$this->info("执行结果: " . $item->content);
}
} else if ($item->role == ChatEnum::AssistantChunk) {
echo $item->getLastAppend();
}
}
echo "\n";
}
}
}

View File

@ -3,6 +3,8 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Assistant;
use App\Models\Chat;
use Illuminate\Http\Request;
class ChatController extends Controller
@ -10,9 +12,11 @@ class ChatController extends Controller
/**
* Display a listing of the resource.
*/
public function index()
public function index(Request $request)
{
//
return $this->success(
Chat::whereUserId($request->user('api')->id)->get()
);
}
/**
@ -20,7 +24,26 @@ public function index()
*/
public function store(Request $request)
{
//
$request->validate([
'name' => 'string|required',
'assistant_id' => 'exists:assistants,id|required',
]);
$assistant = Assistant::find($request->input('assistant_id'));
if ($assistant->user_id !== $request->user('api')->id) {
return $this->forbidden();
}
$chatModel = new Chat();
$chat = $chatModel->create([
'name' => $request->input('name'),
'assistant_id' => $assistant->id,
'user_id' => $request->user('api')->id,
]);
return $this->created($chat);
}
/**
@ -46,4 +69,11 @@ public function destroy(string $id)
{
//
}
public function histories(Request $request, Chat $chat)
{
return $this->success(
$chat->histories()->get()
);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Chat;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class ChatHistoryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Chat $chat)
{
return $this->success(
$chat->histories()->get()
);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, Chat $chat)
{
// 获取上一条记录
$last_history = $chat->histories()->orderBy('id', 'desc')->first();
// 如果存在
if ($last_history) {
// 如果上一条是 user
if ($last_history->role == 'user') {
// 不允许发送消息
return $this->badRequest('你已经回复过了,请等待 AI 响应。');
}
// 检查缓存是否存在
$last_stream_key = 'chat_history_id:'.$last_history->id;
// 如果存在
if (Cache::has($last_stream_key)) {
return $this->conflict('上一个流信息还没有获取,请等待一分钟后重试。');
}
}
$request->validate([
'message' => 'required',
]);
$chat->histories()->create([
'content' => $request->input('message'),
'role' => 'user',
]);
$random_id = Str::random(20);
$last_stream_key = 'chat_history_id:'.$last_history->id;
// 设置缓存
Cache::put($last_stream_key, $random_id, 60);
Cache::put("chat_history_stream_id:$random_id", $last_history->id, 60);
return $this->success([
'stream_url' => route('chat-stream', $random_id),
]);
}
public function stream(string $stream_id)
{
return $stream_id;
}
}

View File

@ -16,6 +16,7 @@ class JSONRequest
public function handle(Request $request, Closure $next): Response
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}

View File

@ -1,5 +0,0 @@
<?php
namespace App\LLM;
class Base {}

14
app/LLM/BaseLLM.php Normal file
View File

@ -0,0 +1,14 @@
<?php
namespace App\LLM;
use App\Repositories\LLM\History;
use Illuminate\Database\Eloquent\Collection;
interface BaseLLM
{
public function setHistory(History $history);
public function setTools(Collection $tools);
public function streamResponse();
}

View File

@ -2,60 +2,245 @@
namespace App\LLM;
use App\Logic\LLMTool;
use App\Repositories\LLM\AIChunkMessage;
use App\Repositories\LLM\AIToolCallMessage;
use App\Repositories\LLM\History;
use App\Repositories\LLM\ToolRequestMessage;
use App\Repositories\LLM\ToolResponseMessage;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Database\Eloquent\Collection;
class Qwen extends Base
class Qwen implements BaseLLM
{
protected string $model = 'qwen-max';
/**
* Create a new class instance.
*/
protected History $history;
private array $tool_info = [];
private Collection $tools;
private array $tool_functions = [];
const END_OF_MESSAGE = "/\r\n\r\n|\n\n|\r\r/";
private bool $request_again = false;
public function __construct()
{
//
$this->clearToolInfo();
}
public function getResponse(): void
private function clearToolInfo(): void
{
$this->tool_info = [
'function' => [
'name' => '',
'arguments' => '',
],
'index' => 0,
'id' => '',
'type' => 'function',
];
}
public function setHistory(History $history): void
{
$this->history = $history;
}
public function setTools(Collection $tools): void
{
$this->tools = $tools;
foreach ($tools as $tool) {
$this->tool_functions = array_merge($this->tool_functions, $tool->data['tool_functions']);
}
}
/**
* @throws GuzzleException
*/
public function streamResponse()
{
$client = new Client([
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Authorization' => 'Bearer '.config('services.dashscope.api_key'),
'Authorization' => 'Bearer ' . config('services.dashscope.api_key'),
],
]);
$url = config('services.dashscope.api_base').'/compatible-mode/v1/chat/completions';
$url = config('services.dashscope.api_base') . '/compatible-mode/v1/chat/completions';
$messages = [
[
'role' => 'user',
'content' => '你好',
],
];
$jsonBody = json_encode([
'model' => $this->model,
'messages' => $messages,
'stream' => true,
]);
$this->request_again = true;
while ($this->request_again) {
$response = $client->request('POST', $url, [
'body' => $jsonBody,
'stream' => true,
]);
$ai_chunk_message = new AIChunkMessage("");
$request_body = [
'model' => $this->model,
'messages' => $this->history->getForApi(),
'stream' => true,
'tools' => $this->tool_functions,
];
$response = $client->request('POST', $url, [
'body' => json_encode($request_body),
'stream' => true,
]);
$body = $response->getBody();
$buffer = '';
while (!$body->eof()) {
$data = $body->read(1);
$buffer .= $data;
// 读取到 \n\n 则一条消息结束
if (preg_match(self::END_OF_MESSAGE, $buffer)) {
// 去掉 data:
$d = substr($buffer, 5);
// 去掉第一个空格(如果是)
$d = ltrim($d, ' ');
// 去除末尾的 END_OF_MESSAGE
$d = rtrim($d, self::END_OF_MESSAGE);
// 如果是 [DONE]
if ($d == '[DONE]') {
break;
}
if (empty($d)) {
return;
}
$event = json_decode($d, true);
if (isset($event['choices'][0])) {
// 检测是不是 stop
if ($event['choices'][0]['finish_reason'] == 'stop') {
$ai_chunk_message->processing = false;
$this->history->addMessage($ai_chunk_message->toAIMessage());
break;
// yield $ai_chunk_message;
}
$delta = $event['choices'][0]['delta'];
if ($event['choices'][0]['finish_reason'] == 'tool_calls') {
$info = json_decode($this->tool_info['function']['arguments'], true);
if (isset($info['properties'])) {
$info = $info['properties'];
}
yield new ToolRequestMessage(
content: $this->tool_info['function']['name'],
);
$r = $this->callTool($this->tool_info['function']['name'], $info);
// $tool_response = [
// 'name' => $this->tool_info['function']['name'],
// 'role' => 'tool',
// 'content' => $r,
// ];
$tool_call_message = new AIToolCallMessage(content: "");
$tool_call_message->tool_calls = [
$this->tool_info,
];
$tool_response_message = new ToolResponseMessage(content: $r);
$tool_response_message->name = $this->tool_info['function']['name'];
$this->history->addMessage($tool_call_message);
$this->history->addMessage($tool_response_message);
yield $tool_call_message;
yield $tool_response_message;
//
//
//
// $this->history[] = $tool_response;
// yield new ToolResponseMessage(
// content: $tool_response_message->content,
// );
// 清空参数,以备二次调用
$this->clearToolInfo();
// 再次请求以获取结果
$this->request_again = true;
} elseif (isset($delta['tool_calls'])) {
// 更新 tool_info
$call_info = $delta['tool_calls'][0];
if (isset($call_info['id'])) {
$this->tool_info['id'] = $call_info['id'];
}
if (isset($call_info['index'])) {
$this->tool_info['index'] = $call_info['index'];
}
if (isset($call_info['function']['name'])) {
$this->tool_info['function']['name'] = $call_info['function']['name'];
}
if (isset($call_info['function']['arguments'])) {
$this->tool_info['function']['arguments'] .= $call_info['function']['arguments'];
}
} elseif (empty($delta['choices'][0]['finish_reason'])) {
if (!empty($delta['content'])) {
$ai_chunk_message->append($delta['content']);
$ai_chunk_message->processing = true;
yield $ai_chunk_message;
}
$this->request_again = false;
}
}
$buffer = '';
}
}
$body = $response->getBody();
// while (!$body->eof()) {
// echo $body->read(1024);
// }
while (! $body->eof()) {
// $body->read(1);
echo $body->read(1);
}
}
private function callTool($tool_name, $args): string
{
// 遍历 tools, 找到
foreach ($this->tools as $tool) {
foreach ($tool->data['tool_functions'] as $f) {
if ($f['function']['name'] == $tool_name) {
$c = new LLMTool();
$c->setCallbackUrl($tool->data['callback_url']);
$r = $c->callTool($tool_name, $args);
return $r->result;
}
}
}
return "[Hint] 没有找到对应的工具 " . $tool_name . ',你确定是这个吗?';
}
}

5
app/Logic/LLM.php Normal file
View File

@ -0,0 +1,5 @@
<?php
namespace App\Logic;
class LLM {}

53
app/Logic/LLMTool.php Normal file
View File

@ -0,0 +1,53 @@
<?php
namespace App\Logic;
use App\Repositories\LLM\FunctionCall;
use Illuminate\Support\Facades\Http;
class LLMTool
{
protected string $callback_url;
public function setCallbackUrl(string $callback_url): void
{
$this->callback_url = $callback_url;
}
public function callTool(string $function_name, $parameters = []): FunctionCall
{
// 使用 _ 分割
$names = explode('_', $function_name)[0];
$prefix_length = strlen($names) + 1;
$function_name = substr($function_name, $prefix_length);
$http = Http::post($this->callback_url, [
'function_name' => $function_name,
'parameters' => $parameters,
]);
$r = new FunctionCall();
$r->name = $function_name;
$r->parameters = $parameters;
if (! $http->ok()) {
$r->success = false;
$r->result = "[Error] 我们的服务器与工具 $function_name 通讯失败";
}
$d = $http->json();
// 必须有 success 和 message 两个
if (! isset($d['success']) || ! isset($d['message'])) {
$r->success = false;
$r->result = "[Error] 和 工具 $function_name 通讯失败,返回数据格式错误。";
return $r;
}
$r->success= $d['success'];
$r->result = $d['message'];
return $r;
}
}

View File

@ -8,6 +8,14 @@
class Chat extends Model
{
public const ROLE_USER = 'user';
public const ROLE_ASSISTANT = 'assistant';
public const ROLE_SYSTEM = 'system';
public const ROLE_META = 'meta';
protected $fillable = [
'name',
'assistant_id',

View File

@ -30,5 +30,4 @@ public function functions(): HasMany
{
return $this->hasMany(ToolFunction::class);
}
}

View File

@ -41,7 +41,7 @@ private function setJWTGuard(): void
if (empty($jwt)) {
return null;
// return response()->json(['error' => 'No token provided'], 401);
// return response()->json(['error' => 'No token provided'], 401);
}
$headers = new stdClass();
@ -50,18 +50,17 @@ private function setJWTGuard(): void
$decoded = JWT::decode($jwt, $keys, $headers);
// $request->attributes->add(['token_type' => $headers->typ]);
} catch (Exception $e) {
// dd($e);
// dd($e);
return null;
// return response()->json(['error' => 'Invalid token, '.$e->getMessage()], 401);
// return response()->json(['error' => 'Invalid token, '.$e->getMessage()], 401);
}
// must id_token
if ($headers->typ !== 'id_token') {
return null;
// return response()->json(['error' => 'The token not id_token'], 401);
// return response()->json(['error' => 'The token not id_token'], 401);
}
// 检查是否有 字段
@ -70,22 +69,22 @@ private function setJWTGuard(): void
];
foreach ($required_fields as $field) {
if (!isset($decoded->$field)) {
if (! isset($decoded->$field)) {
return null;
// return response()->json(['error' => 'The token not contain the field '.$field], 401);
// return response()->json(['error' => 'The token not contain the field '.$field], 401);
}
}
if (config('oauth.force_aud')) {
if (!in_array($decoded->aud, config('oauth.trusted_aud'))) {
throw new Exception('The application rejected the token, token aud is ' . $decoded->aud . ', app aud is ' . config('oauth.client_id'));
// return response()->json(['error' => 'The application rejected the token, token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401);
if (! in_array($decoded->aud, config('oauth.trusted_aud'))) {
throw new Exception('The application rejected the token, token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id'));
// return response()->json(['error' => 'The application rejected the token, token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401);
}
// throw
throw new Exception('The token not match the application, ' . ' token aud is ' . $decoded->aud . ', app aud is ' . config('oauth.client_id'));
// return response()->json(['error' => 'The token not match the application, '.' token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401);
throw new Exception('The token not match the application, '.' token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id'));
// return response()->json(['error' => 'The token not match the application, '.' token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401);
}
return User::where('external_id', $decoded->sub)->firstOrCreate([

View File

@ -0,0 +1,30 @@
<?php
namespace App\Repositories\LLM;
class AIChunkMessage extends BaseMessage
{
public ChatEnum $role = ChatEnum::AssistantChunk;
protected string $last_append = "";
public function toAIMessage(): AIMessage
{
$a = new AIMessage(
content: $this->content,
);
$a->processing = false;
return $a;
}
public function append(string $content): void
{
$this->content .= $content;
$this->last_append = $content;
}
public function getLastAppend(): string
{
return $this->last_append;
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Repositories\LLM;
class AIMessage extends BaseMessage
{
public ChatEnum $role = ChatEnum::Assistant;
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Repositories\LLM;
class AIToolCallMessage extends BaseMessage
{
public ChatEnum $role = ChatEnum::Assistant;
public array $tool_calls;
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Repositories\LLM;
class BaseMessage
{
public string $content;
public bool $processing = false;
public function __construct(string $content)
{
$this->content = $content;
}
public function append(string $content): void
{
$this->content .= $content;
}
public function clear(): void
{
$this->content = '';
}
public function __toString(): string
{
return $this->content;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Repositories\LLM;
enum ChatEnum: string
{
case Assistant = 'assistant';
case AssistantChunk = 'assistant_chunk';
case Tool = 'tool';
case Human = 'user';
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Repositories\LLM;
class ChatResponse
{
public ChatEnum $type;
public FunctionCall $functionCall;
public string $content;
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories\LLM;
class FunctionCall
{
public string $name;
public array $parameters;
public bool $success;
public string $result;
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Repositories\LLM;
class History
{
protected array $history = [];
public function addMessage(BaseMessage $message): void
{
$this->history[] = $message;
}
public function getMessages(): array
{
return $this->history;
}
public function setHistory(array $history): void
{
$this->history = $history;
}
public function clearHistory(): void
{
$this->history = [];
}
public function getForApi(): array
{
$history = [];
foreach ($this->history as $h) {
$a = [
'role' => $h->role->value,
'content' => $h->content,
];
if (isset($h->tool_calls)) {
$a['tool_calls'] = $h->tool_calls;
}
$history[] = $a;
}
return $history;
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Repositories\LLM;
class HumanMessage extends BaseMessage
{
public ChatEnum $role = ChatEnum::Human;
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Repositories\LLM;
class ToolMessage extends BaseMessage
{
public ChatEnum $role = ChatEnum::Tool;
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Repositories\LLM;
class ToolRequestMessage extends BaseMessage
{
public ChatEnum $role = ChatEnum::Tool;
public bool $processing = true;
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Repositories\LLM;
class ToolResponseMessage extends BaseMessage
{
public ChatEnum $role = ChatEnum::Tool;
public bool $requesting = false;
public string $name;
}

View File

@ -14,7 +14,7 @@ class Tool
public string $description;
public array $toolFunctions;
public array $tool_functions;
private array $data;
@ -63,7 +63,12 @@ private function validate(): bool
private function fetchFunctions(): void
{
foreach ($this->data['functions'] as $f) {
$this->toolFunctions[] = new ToolFunction($f);
$a = [
'type' => 'function',
'function' => new ToolFunction($f),
];
$this->tool_functions[] = $a;
}
}
}

View File

@ -3,6 +3,7 @@
namespace App\Repositories\Tool;
use Exception;
use Illuminate\Support\Str;
class ToolFunction
{
@ -15,7 +16,6 @@ class ToolFunction
protected array $data;
public array $required;
/**
* Create a new class instance.
*
@ -32,7 +32,8 @@ public function __construct(array $data)
*/
private function parse(): void
{
$this->name = $this->data['name'];
$random_str = Str::random(config('settings.function_call.random_prefix_length'));
$this->name = $random_str . '_' . $this->data['name'];
$this->description = $this->data['description'];
// 如果 parameters 不为空,则验证

View File

@ -19,7 +19,7 @@
// ]);
// add for api
$middleware->api([
JSONRequest::class
JSONRequest::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {

View File

@ -42,7 +42,7 @@
],
'api' => [
'driver' => 'jwt',
// 'provider' => 'users',
// 'provider' => 'users',
],
],

View File

@ -40,4 +40,5 @@
'api_base' => env('DASHSCOPE_API_BASE', 'https://dashscope.aliyuncs.com'),
],
];

8
config/settings.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'function_call' => [
// 防止命名冲突的随机长度,如果你更改了它,则需要更新全部函数的 name
'random_prefix_length' => 8
]
];

View File

@ -21,12 +21,12 @@ public function up(): void
// $table->string('model');
// input tokens
$table->integer('input_tokens')->default(null);
$table->integer('input_tokens')->nullable();
// output tokens
$table->integer('output_tokens')->default(null);
$table->integer('output_tokens')->nullable();
$table->integer('total_tokens')->default(null);
$table->integer('total_tokens')->nullable();
// index
$table->index(['chat_id', 'role']);

View File

@ -3,8 +3,8 @@
use App\Http\Controllers\Api\AssistantController;
use App\Http\Controllers\Api\AssistantToolController;
use App\Http\Controllers\Api\ChatController;
use App\Http\Controllers\Api\ChatHistoryController;
use App\Http\Controllers\Api\ToolController;
use App\Http\Middleware\JSONRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@ -16,8 +16,11 @@
Route::apiResource('tools', ToolController::class);
Route::apiResource('assistants', AssistantController::class);
Route::apiResource('chats', ChatController::class);
Route::apiResource('chats.histories', ChatHistoryController::class)->only(['index', 'store']);
Route::get('assistants/{assistant}/tools', [AssistantToolController::class, 'index']);
Route::post('assistants/{assistant}/tools', [AssistantToolController::class, 'store']);
Route::delete('assistants/{assistant}/tools/{tool}', [AssistantToolController::class, 'destroy']);
});
Route::get('chat_stream/{stream_id}', [ChatHistoryController::class, 'stream'])->name('chat-stream');