原理阐明
流式接纳 OpenAI 的回来数据
后端 Class.ChatGPT.php 顶用 curl 向 OpenAI 建议恳求,运用 curl 的 CURLOPT_WRITEFUNCTION
设置回调函数,一起恳求参数里 'stream' => true
告知 OpenAI 敞开流式传输。
咱们经过 curl_setopt($ch, CURLOPT_WRITEFUNCTION, [$this->streamHandler, 'callback']);
设置运用 StreamHandler 类的实例化对象 $this->streamHandler
的 callback
方法来处理 OpenAI 回来的数据。
OpenAI 会在模型每次输出时回来 data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]}
格式字符串,其中咱们需求的答复就在 choices[0]['delta']['content']
里,当然咱们也要做好异常判别,不能直接这样获取数据。
另外,实际由于网络传输问题,每次 callback
函数收到的数据并不一定只要一条 data: {"key":"value"}
格式的数据,有或许只要半条,也有或许有多条,还有或许有N条半。
所以咱们在 StreamHandler
类中增加了 data_buffer
属性来存储无法解析的半条数据。
这儿依据 OpenAI 的回来数据格式,做了一些特别处理,详细代码如下:
public function callback($ch, $data) { $this->counter += 1; file_put_contents('./log/data.'.$this->qmd5.'.log', $this->counter.'=='.$data.PHP_EOL.'--------------------'.PHP_EOL, FILE_APPEND); $result = json_decode($data, TRUE); if(is_array($result)){ $this->end('openai 恳求过错:'.json_encode($result)); return strlen($data); } /* 此处过程仅针对 openai 接口而言 每次触发回调函数时,里面会有多条data数据,需求切割 如某次收到 $data 如下所示: data: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}\n\ndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"以下"},"index":0,"finish_reason":null}]}\n\ndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"是"},"index":0,"finish_reason":null}]}\n\ndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"运用"},"index":0,"finish_reason":null}]} 最终两条一般是这样的: data: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}\n\ndata: [DONE] 依据以上 openai 的数据格式,切割过程如下: */ // 0、把上次缓冲区内数据拼接上本次的data $buffer = $this->data_buffer.$data; // 1、把一切的 'data: {' 替换为 '{' ,'data: [' 换成 '[' $buffer = str_replace('data: {', '{', $buffer); $buffer = str_replace('data: [', '[', $buffer); // 2、把一切的 '}\n\n{' 替换维 '}[br]{' , '}\n\n[' 替换为 '}[br][' $buffer = str_replace('}'.PHP_EOL.PHP_EOL.'{', '}[br]{', $buffer); $buffer = str_replace('}'.PHP_EOL.PHP_EOL.'[', '}[br][', $buffer); // 3、用 '[br]' 切割成多行数组 $lines = explode('[br]', $buffer); // 4、循环处理每一行,关于最终一行需求判别是否是完好的json $line_c = count($lines); foreach($lines as $li=>$line){ if(trim($line) == '[DONE]'){ //数据传输完毕 $this->data_buffer = ''; $this->counter = 0; $this->sensitive_check(); $this->end(); break; } $line_data = json_decode(trim($line), TRUE); if( !is_array($line_data) || !isset($line_data['choices']) || !isset($line_data['choices'][0]) ){ if($li == ($line_c - 1)){ //假如是最终一行 $this->data_buffer = $line; break; } //假如是中心行无法json解析,则写入过错日志中 file_put_contents('./log/error.'.$this->qmd5.'.log', json_encode(['i'=>$this->counter, 'line'=>$line, 'li'=>$li], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT).PHP_EOL.PHP_EOL, FILE_APPEND); continue; } if( isset($line_data['choices'][0]['delta']) && isset($line_data['choices'][0]['delta']['content']) ){ $this->sensitive_check($line_data['choices'][0]['delta']['content']); } } return strlen($data); }
灵敏词检测
咱们运用了 DFA 算法来完成灵敏词检测,依照 ChatGPT 的解说,"DFA"是指“确定性有限自动机”(Deterministic Finite Automaton)
,DfaFilter(确定有限自动机过滤器)通常是指一种用于文本处理和匹配的算法
。
Class.DFA.php 类代码是 GPT4 写的,详细完成代码见源码。
这儿介绍一下运用方法,创立一个 DFA 实例需求传入灵敏词文件路径:
$dfa = new DFA([ 'words_file' => './sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt', ]);
特别阐明:这儿特意用乱码字符串文件名是为了防止他人下载灵敏词文件,请你布置后也自己改一个其他乱码文件名,不要运用我这儿公开了的文件名
之后就能够用 $dfa->containsSensitiveWords($inputText)
来判别 $inputText
是否包括灵敏词,回来值是 TRUE
或 FALSE
的布尔值,也能够用 $outputText = $dfa->replaceWords($inputText)
来进行灵敏词替换,一切在 sensitive_words.txt
中指定的灵敏词都会被替换为三个*
号。
假如不想敞开灵敏词检测,把 chat.php
中的以下三句注释掉即可:
$dfa = new DFA([ 'words_file' => './sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt', ]);$chat->set_dfa($dfa);
假如没有敞开灵敏词检测,那么每次 OpenAI 的回来都会实时回来给前端。
假如敞开了灵敏词检测,会查找 OpenAI 回来中的换行符和中止符号 [',', '。', ';', '?', '!', '……']
等来进行分句,每一句都运用 $outputText = $dfa->replaceWords($inputText)
来替换灵敏词,之后整句回来给前端。
敞开灵敏词后,加载灵敏词文件需求时刻,每次检测时也是逐句检测,而不是逐词检测,也会导致回来变慢。
所以假如是自用,能够不敞开灵敏词检测,假如是布置出去给其他人用,为了保护你的域名安全和你的安全,最好敞开灵敏词检测。
流式回来给前端
直接看 chat.php
的注释会更清楚:
/* 以下几行注释由 GPT4 生成 */ // 这行代码用于封闭输出缓冲。封闭后,脚本的输出将当即发送到浏览器,而不是等待缓冲区填满或脚本执行完毕。 ini_set('output_buffering', 'off'); // 这行代码禁用了 zlib 紧缩。通常情况下,启用 zlib 紧缩能够减小发送到浏览器的数据量,但关于服务器发送事情来说,实时性更重要,因此需求禁用紧缩。 ini_set('zlib.output_compression', false); // 这行代码运用循环来清空一切当前激活的输出缓冲区。ob_end_flush() 函数会改写并封闭最内层的输出缓冲区,@ 符号用于按捺或许出现的过错或正告。 while (@ob_end_flush()) {} // 这行代码设置 HTTP 呼应的 Content-Type 为 text/event-stream,这是服务器发送事情(SSE)的 MIME 类型。 header('Content-Type: text/event-stream'); // 这行代码设置 HTTP 呼应的 Cache-Control 为 no-cache,告知浏览器不要缓存此呼应。 header('Cache-Control: no-cache'); // 这行代码设置 HTTP 呼应的 Connection 为 keep-alive,保持长衔接,以便服务器能够持续发送事情到客户端。 header('Connection: keep-alive'); // 这行代码设置 HTTP 呼应的自定义头部 X-Accel-Buffering 为 no,用于禁用某些代理或 Web 服务器(如 Nginx)的缓冲。 // 这有助于保证服务器发送事情在传输过程中不会受到缓冲影响。 header('X-Accel-Buffering: no');
echo 'data: '.json_encode(['time'=>date('Y-m-d H:i:s'), 'content'=>'答: ']).PHP_EOL.PHP_EOL; flush();
这儿咱们定义了咱们自己运用的一个数据格式,里面只放了 time 和 content ,不必解说都懂,time 是时刻, content 便是咱们要回来给前端的内容。
注意,答复悉数传输完毕后,咱们需求封闭衔接,能够用以下代码:
echo 'retry: 86400000'.PHP_EOL; // 告知前端假如产生过错,隔多久之后才轮询一次 echo 'event: close'.PHP_EOL; // 告知前端,完毕了,该说再见了 echo 'data: Connection closed'.PHP_EOL.PHP_EOL; // 告知前端,衔接已封闭 flush();
EventSource
前端 js 经过 const eventSource = new EventSource(url);
敞开一个 EventSource 恳求。
之后服务器依照 data: {"kev1":"value1","kev2":"value2"}
格式向前端发送数据,前端就能够在 EventSource 的 message 回调事情中的 event.data
里获取 {"kev1":"value1","kev2":"value2"}
字符串形式 json 数据,再经过 JSON.parse(event.data)
就能够得到 js 对象。
详细代码在 getAnswer 函数中,如下所示:
function getAnswer(inputValue){ inputValue = inputValue.replace('+', '{[$add$]}'); const url = "./chat.php?q="+inputValue; const eventSource = new EventSource(url); eventSource.addEventListener("open", (event) => { console.log("衔接已建立", JSON.stringify(event)); }); eventSource.addEventListener("message", (event) => { //console.log("接纳数据:", event); try { var result = JSON.parse(event.data); if(result.time && result.content ){ answerWords.push(result.content); contentIdx += 1; } } catch (error) { console.log(error); } }); eventSource.addEventListener("error", (event) => { console.error("产生过错:", JSON.stringify(event)); }); eventSource.addEventListener("close", (event) => { console.log("衔接已封闭", JSON.stringify(event.data)); eventSource.close(); contentEnd = true; console.log((new Date().getTime()), 'answer end'); }); }
阐明一下,原生的 EventSource
恳求,只能是 GET
恳求,所以这儿演示时,直接把发问放到 GET
的 URL
参数里了。
假如要想用 POST
恳求,一般有两种方法:
前后端一起改:【先发
POST
后发GET
】用POST
向后端发问,后端依据发问和时刻生成一个仅有 key 跟着POST
恳求回来给前端,前端拿到后,再建议一个GET
恳求,在参数里带着问题 key ,获取答复,这种方式需求修正后端代码;只改前端:【只发一个
POST
恳求】后端代码不必大改,只需求把chat.php
中$question = urldecode($_GET['q'] ?? '')
改为$question = urldecode($_POST['q'] ?? '')
即可,但是前端需求改造,不能用原生EventSource
恳求,需求用 fetch ,设置流式接纳,详细可见下方 GPT4 给出的代码示例。
async function fetchAiResponse(message) { try { const response = await fetch("./chat.php", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages: [{ role: "user", content: message }] }), }); if (!response.ok) { throw new Error(response.statusText); } const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); while (true) { const { value, done } = await reader.read(); if (value) { const partialResponse = decoder.decode(value, { stream: true }); displayMessage("assistant", partialResponse); } if (done) { break; } } } catch (error) { console.error("Error fetching AI response:", error); displayMessage("assistant", "Error: Failed to fetch AI response."); } }
上方代码,要害点在于 const partialResponse = decoder.decode(value, { stream: true })
中的 { stream: true }
。
打字机效果
关于后端回来的一切回复内容,咱们需求用打字机形式打印出来。
开端的方案是每次接纳到后端的回来后就当即显现到页面里,后来发现这样速度太快了,眨眼就显现完了,没有打印机效果。
所以后来的方案就改成了用守时器完成守时打印,那么就需求把收到的先放进数组里缓存起来,然后守时每 50 毫秒执行一次,打印一个内容出来。
详细完成代码如下:
function typingWords(){ if(contentEnd && contentIdx==typingIdx){ clearInterval(typingTimer); answerContent = ''; answerWords = []; answers = []; qaIdx += 1; typingIdx = 0; contentIdx = 0; contentEnd = false; lastWord = ''; lastLastWord = ''; input.disabled = false; sendButton.disabled = false; console.log((new Date().getTime()), 'typing end'); return; } if(contentIdx<=typingIdx){ return; } if(typing){ return; } typing = true; if(!answers[qaIdx]){ answers[qaIdx] = document.getElementById('answer-'+qaIdx); } const content = answerWords[typingIdx]; if(content.indexOf('`') != -1){ if(content.indexOf('```') != -1){ codeStart = !codeStart; }else if(content.indexOf('``') != -1 && (lastWord + content).indexOf('```') != -1){ codeStart = !codeStart; }else if(content.indexOf('`') != -1 && (lastLastWord + lastWord + content).indexOf('```') != -1){ codeStart = !codeStart; } } lastLastWord = lastWord; lastWord = content; answerContent += content; answers[qaIdx].innerHTML = marked.parse(answerContent+(codeStart?'\n\n```':'')); typingIdx += 1; typing = false; }
代码渲染
假如严格依照输出什么打印什么的话,那么当正在打印一段代码,需求比及代码悉数打完,才干被格式化为代码块,才干高亮显现代码。
那这个体会也太差了。
有什么方法能够处理这个问题呢?
答案就在问题里,既然是由于代码块有开端符号没有完毕符号,那就咱们给他补全完毕符号就好了,直到真的完毕符号来了,才不需求补全。
详细的完成便是下面几行代码:
if(content.indexOf('`') != -1){ if(content.indexOf('```') != -1){ codeStart = !codeStart; }else if(content.indexOf('``') != -1 && (lastWord + content).indexOf('```') != -1){ codeStart = !codeStart; }else if(content.indexOf('`') != -1 && (lastLastWord + lastWord + content).indexOf('```') != -1){ codeStart = !codeStart; } } lastLastWord = lastWord; lastWord = content; answerContent += content; answers[qaIdx].innerHTML = marked.parse(answerContent+(codeStart?'\n\n```':''));
其它
更多其它细节请看代码,假如对代码有疑问的,请加我微信(同 GitHub id)
目录结构
/ ├─ /class │ ├─ Class.ChatGPT.php │ ├─ Class.DFA.php │ ├─ Class.StreamHandler.php ├─ /static │ ├─ css │ │ ├─ chat.css │ │ ├─ monokai-sublime.css │ ├─ js │ │ ├─ chat.js │ │ ├─ highlight.min.js │ │ ├─ marked.min.js ├─ /chat.php ├─ /index.html ├─ /README.md ├─ /sensitive_words.txt
目录/文件 | 阐明 |
---|---|
/ | 程序根目录 |
/class | php类文件目录 |
/class/Class.ChatGPT.php | ChatGPT 类,用于处理前端恳求,并向 OpenAI 接口提交恳求 |
/class/Class.DFA.php | DFA 类,用于灵敏词校验和替换 |
/class/Class.StreamHandler.php | StreamHandler 类,用于实时处理 OpenAI 流式回来的数据 |
/static | 寄存一切前端页面所需的静态文件 |
/static/css | 寄存前端页面一切的 css 文件 |
/static/css/chat.css | 前端页面谈天款式文件 |
/static/css/monokai-sublime.css | highlight 代码高亮插件的主题款式文件 |
/static/js | 寄存前端页面一切的 js 文件 |
/static/js/chat.js | 前端谈天交互 js 代码 |
/static/js/highlight.min.js | 代码高亮 js 库 |
/static/js/marked.min.js | markdown 解析 js 库 |
/chat.php | 前端谈天恳求的后端进口文件,在这儿引进 php 类文件 |
/index.html | 前端页面 html 代码 |
/README.md | 仓库描述文件 |
/sensitive_words.txt | 灵敏词文件,一行一个灵敏词,需求你自己搜集灵敏词,也能够加我微信(同 GitHub id)找我要 |
根据以上原理,我完成了一个 demo ,开源到了 Github 上,欢迎 star 和 fork 。
项目地址: github.com/qiayue/php-…
运用方法
本项目代码,没有运用任何框架,也没有引进任何第三方后端库,前端引进了代码高亮库 highlight 和 markdown 解析库 marked 都已经下载项目内了,所以拿到代码不必任何安装即可直接运用。
唯二要做的便是把你自己的 api key 填进去。
获取源码后,修正 chat.php
,填写 OpenAI 的 api key 进去,详细请见:
$chat = new ChatGPT([ 'api_key' => '此处需求填入 openai 的 api key ', ]);
假如敞开灵敏词检测功用,需求把灵敏词一行一个放入 sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt
文件中。