灵感来源于之前在浏览 HEO 博文时候偶然看到文章前有一段 AI 摘要,第三人称以打字形式来简述文章内容还是蛮酷的~ 于是拟了个把这个功能集成到 2BLOG 主题的计划。之前也用过 chatGPT,感觉这个需求应该不是很难,毕竟直接在 chat.openai.com 提问也可以拿到结果。因 eventStream 流式传输比较繁杂的原因(懒),故本文主要方式为简单粗暴的直接请求 chatGPT 返回响应结果。
近期本地测试的差不多了,所以发一篇 chatGPT 具体实现思路顺便也好做个首次AI应用的记录,鉴于请求付费问题,目前仅用于日志记录文章页面(本文例外)。
注:文章仅作个人记录,部分内容尚未开发完善,代码仅供参考,可能无法适用部分情况
准备工作
一切操作的起源,所有数据均由 chatGPT 生成后进行调用,故需注册一枚 OPENAI 账号(注册流程自行检索,我这里用的接?平台是比较熟悉的 sms-activate,充了2刀,当时选的号段是印度尼西亚的,直接过了),注册后进入账号设置中获取 API Keys。
需要注意的是,每个免费账号只有5美刀的有限期内额度,留意:chatGPT 目前已于4月13日对免费账号进行限速处理,参考:
最近速度慢是因为 OpenAI 对于免费账号限速了,在 platform.openai.com 绑定了信用卡的才是之前的正常速度; 限速指的是流式请求时,首个 token 返回需要 20 秒左右,而绑定了信用卡的账号,在 2 秒左右;
反代 API
默认情况下使用 chatGPT 官方文档中提供的 api 调用地址 https://api.openai.com/v1/completions
在大陆是调不通的,所以我们需要另外自行准备一台国外VPS服务器来做反向代理我们自定义的域名(其中宝塔 nginx 环境参考配置如下:(要把伪静态啥的先关掉
location ^~ / {
proxy_pass https://api.openai.com;
proxy_set_header Host api.openai.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header REMOTE-HOST $remote_addr;
proxy_ssl_server_name on; #注意此行为开启HTTPS后必填
add_header X-Cache $upstream_cache_status;
#Set Nginx Cache
set $static_filebZf74aXt 0;
if ( $uri ~* ".(gif|png|jpg|css|js|woff|woff2)$" ){
set $static_filebZf74aXt 1;
expires 12h;
}
if ( $static_filebZf74aXt = 0 ){
add_header Cache-Control no-cache;
}
}
自此,我们手里应该已经有了 chatGPT 的 API Keys 和自定义反代的的 API 的地址。
实现流程
其实思路很简单,甚至你可以注册账号后直接在 chat.openai.com 提问即可,这里的实现思路仅供参考。首先是运行环境,我目前使用的博客是 wordpress 平台,所以在 php 环境搭建,这里的实现方式和之前实现企业微信推送评论提醒略有相似之处,比如本地缓存等。
在 chatGPT API 文档中提到有多种对话模式,text-davinci-003
、gpt-3.5-turbo
等(具体花费金额也不同,可在官网查看),像实现文章摘要这种无需交互的功能,使用 text-davinci-003 就可以,最好别使用这个接口,太tm费token了!相比之下用 gpt-3.5-turbo
会好很多倍,体验基本都一样(有时甚至更好)。先在后台预置好 php 接口,然后在前端异步调用 php 文件接口返回数据即可(前端模拟打字效果) 。
后端
首先获取GET、POST接收请求数据为文章 $post->ID
(后设置具体请求数据),拿到 id 后组合chatGPT请求数据内容,再通过 curl
发送 chatGPT 反代 API 请求以获取 chatGPT 返回数据(发送请求后随即将请求记录到本地防止并发同一请求),拿到数据后再将实际返回数据覆写到本地记录,最后返回过滤结果到前端操作。
执行 chatGPT 请求后会在同目录生成名为 chat_data.php
文件,该文件为本地缓存,首次请求写入后续将直接从文件读取数据以避免 chatGPT 重复请求造成多次付费。如需更新摘要内容需要手动定位文章id进行删除,暂无集成删除控件计划到主题(已实现,正在集成中..,已集成至 beta-v1.3.7.8
),尚未挂载 wp 文章发布更新 hook。
接受参数为 pid
,已做错误处理,代码仅供参考,其中部分内容为 wordpress 主题集成所用,不保证兼容及实际可行性。
<?php
parse_str($_SERVER['QUERY_STRING'], $Params);
// 判断url传参或form表单参数
$pid = array_key_exists('pid', $Params) ? $Params['pid'] : $_POST['pid'];
if($pid){
define('WP_USE_THEMES', false); // No need for the template engine
require_once($_SERVER['DOCUMENT_ROOT'].'/wp-load.php'); // Load WordPress Core
$pids = get_post($pid);
$title = $pids->post_title;
$author = get_the_author_meta('display_name', get_post_field('post_author', $pid));
$content = str_replace(array("rn", "r", "n"), " ", wp_strip_all_tags($pids->post_content));
$post_type = $pids->post_type;
$post_exist = get_post_status($pid);
$requirements = '标题:'.$title.',作者:'.$author.',内容:'.$content;
define('OPENAI_API_KEY', 'apikey'); //替换 API Keys
define('OPENAI_PROXY', 'https://api.openai.com'); //替换代理API
define('OPENAI_MODEL', 'text-davinci-003'); //可选 gpt-3.5-turbo
function curlRequest($question, $maxlen=1024) {
$gpt_turbo = OPENAI_MODEL==='gpt-3.5-turbo';
$post_data = array(
"model" => OPENAI_MODEL,
'temperature' => 0.8,
"max_tokens" => $maxlen, // works for completion_tokens only
"prompt" => $question.'。分析上述内容,简述文章用意,注意精简字数',
);
if($gpt_turbo){
unset($post_data['prompt']);
$post_data = array_merge($post_data, array('messages' => [["role" => "system", "content" => '分析文章内容,简述文章用意,注意精简字数'],["role" => "user", "content" => $question]]));
}
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => $gpt_turbo ? OPENAI_PROXY.'/v1/chat/completions' : OPENAI_PROXY.'/v1/completions', //聊天模型
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode($post_data),
CURLOPT_HTTPHEADER => array(
"Content-Type: application/json",
"Authorization: Bearer " . OPENAI_API_KEY
),
));
$res = curl_exec($curl);
curl_close($curl);
return $res;
}
header("Content-type:text/html;charset=utf-8"); // 声明页面header
$cached_post = array();
$cached_path = './chat_data.php';
// 初始化php文件,返回记录
function chatGPT_init($caches, $new=false){
global $pid, $requirements, $cached_path, $response, $post_exist, $post_type;
switch (true) {
case !$post_exist:
$err_msg = 'Unabled to reach request post: not found';
break;
case $post_type!=='post':
$err_msg = 'Unsupported request post type found: pid';
break;
default:
$err_msg = NULL;
break;
}
$request_ip = $_SERVER["REMOTE_ADDR"];
$request_ua = $_SERVER["HTTP_USER_AGENT"];
// 创建临时记录,防止多请求并发
$caches['chat_pid_'.$pid] = array('error' => array ('message' => 'standby, another requesting in busy..','type' => 'request_inqueue_busy','created'=>time(),'ip'=>$request_ip,'ua'=>$request_ua));
$temp = '<?php'.PHP_EOL.'$cached_post = '.var_export($caches,true).';'.PHP_EOL.'?>';
$newfile = fopen($cached_path,"w");
fwrite($newfile, $temp);
fclose($newfile);
// 生成 chatGPT 请求数据
$caches['chat_pid_'.$pid] = $post_exist&&$post_type==='post' ? json_decode(curlRequest($requirements, 256), true) : array('error' => array ('message' => $err_msg,'type' => 'invalid_request_param','created'=>time(),'ip'=>$request_ip,'ua'=>$request_ua));
$arr = '<?php'.PHP_EOL.'$cached_post = '.var_export($caches,true).';'.PHP_EOL.'?>';
$arrfile = fopen($cached_path,"w");
fwrite($arrfile, $arr);
fclose($arrfile);
// 读取临时/已请求记录
$response = json_encode($caches['chat_pid_'.$pid]); //$caches['chat_pid_'.$pid]
}
//overwrite response record
if(!file_exists($cached_path)){
chatGPT_init($cached_post); // 文件不存在,创建文件后新增记录
}else{
include $cached_path; // 读取文件记录
if(array_key_exists('chat_pid_'.$pid, $cached_post)){
$response = json_encode($cached_post['chat_pid_'.$pid]); //$cached_post['chat_pid_'.$pid]
}else{
chatGPT_init($cached_post); // 记录不存在,新增记录
}
}
// formart responses text-result
$response = json_decode($response);
if(array_key_exists('error', $response)){
$response = $response->error->message;
}else{
$choices = $response->choices[0];
$response = array_key_exists('message', $choices) ? $choices->message->content : $choices->text;
$response = preg_replace('/.*n/','', $response);
}
}else{
$response = 'arguments [pid] err';
}
print_r($response);
?>
前端
这里直接发送 xhr/ajax/fetch 等请求到后端接口(上述后端文件路径),传入 pid
参数为文章 id 即可。前端样式效果可根据个人喜欢定制,这里主要写个简单打字机的效果,该方法接受三个参数:输出的dom元素、接口返回的字符串以及打字速度ms。
function words_typer(el, str, speed=100){
try{
if(!str||typeof(str)!='string'||str.replace(/^s |s $/g,"").replace( /^s*/, '')=="") throw new Error("invalid string");
new Promise(function(resolve,reject){
setTimeout(() => {
el.classList.remove('load');
for(let i=0,textLen=el.innerText.length;i<textLen;i ){
// real-time data stream
let elText = el.innerText,
elLen = elText.length-1;
setTimeout(() => {
el.innerText = elText.slice(0, elLen-i); // console.log(i '-' elLen);
if(i===elLen) resolve(el);
}, i*5);
}
}, 700);
}).then((res)=>{
setTimeout(() => {
res.classList.remove('load');
for(let i=0,strLen=str.length;i<strLen;i ){
setTimeout(() => {
res.innerText = str[i]; // console.log(str[i]);
if(i 1===strLen) res.classList.add('done');
}, i*speed);
}
}, 300);
}).catch(function(err){
console.log(err)
});
}catch(err){
console.log(err);
}
};
// 示例 fetch 请求,修改 dom 为输出元素,pid 为文章 id
fetch('https://xxx.com/gpt.php?pid=xxx').then(res=>{
words_typer(document.querySelector('dom'), res, 25);
});
扩展内容
出于安全考量还是建议在 api 上再套一层 cdn(如 cloudflare 等) 用作 api 请求缓存设置请求鉴权限制等。
另外还有个已知问题:当文章过于冗长时,发送请求会触发最大 max_tokens
限制,需要额外做分段请求后进行拼接处理,包括请求字段过长时被截断的问题,这里由于请求付费等原因,暂且搁置。
集成特性
将该功能集成到 wordpress 主题控件后支持了以下两个特性:可自定义分类文章是否开启数据调用以及可选的 chatGPT 对话模型。0426更新:新增 api 文件中转鉴权域名请求 api,适用前端 xhr 校验,可配合 nginx 配置域名请求路径。0509更新:集成本地缓存控制到后台管理面板,已做WP鉴权。
4月29更新
现已支持长篇文章摘要,具体实现为当文章字符请求总数所需 token 超过 4096 时将分割文章为上下文两段并分别请求摘要,完成后再合并上下文摘要请求全文综合摘要。控件目前已集成到 2BLOG 主题面板,相关 gpt 更新内容后续推送至 github,已更新至 beta-v1.3.7.5
(现已支持当文章过于冗长导致下半段请求失败时,可选仅摘要首次 文章尾段综合摘要)
增强 api 调用安全逻辑,集成 CDN 请求验证等。
参考链接
charGPT API Refrence:https://platform.openai.com/docs/api-reference/introduction
Best practices for prompt engineering with OpenAI API