由@qiayue 開源的純PHP 實現GPT 流式調用和前端實時打印webui 。
4月13日更新:
1、最近速度慢是因為OpenAI 對於免費賬號限速了,在platform.openai.com 綁定了信用卡的才是之前的正常速度;
2、限速指的是流式請求時,首個token 返回需要20 秒左右,而綁定了信用卡的賬號,在2 秒左右;
/
├─ /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)找我要 |
本項目代碼,沒有使用任何框架,也沒有引入任何第三方後端庫,前端引入了代碼高亮庫highlight 和markdown 解析庫marked 都已經下載項目內了,所以拿到代碼不用任何安裝即可直接使用。
唯二要做的就是把你自己的api key 填進去。
獲取源碼後,修改chat.php ,填寫OpenAI 的api key 進去,具體請見:
$ chat = new ChatGPT ([
' api_key ' => '此处需要填入 openai 的 api key ' ,
]);如果開啟敏感詞檢測功能,需要把敏感詞一行一個放入sensitive_words_sdfdsfvdfs5v56v5dfvdf.txt文件中。
開了一個微信群,歡迎入群交流:
後端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}]}nndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"以下"},"index":0,"finish_reason":null}]}nndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"是"},"index":0,"finish_reason":null}]}nndata: {"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"}]}nndata: [DONE]
根据以上 openai 的数据格式,分割步骤如下:
*/
// 0、把上次缓冲区内数据拼接上本次的data
$ buffer = $ this -> data_buffer . $ data ;
//拼接完之后,要把缓冲字符串清空
$ this -> data_buffer = '' ;
// 1、把所有的 'data: {' 替换为 '{' ,'data: [' 换成 '['
$ buffer = str_replace ( ' data: { ' , ' { ' , $ buffer );
$ buffer = str_replace ( ' data: [ ' , ' [ ' , $ buffer );
// 2、把所有的 '}nn{' 替换维 '}[br]{' , '}nn[' 替换为 '}[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 ();前端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 ? 'nn```' : '' ) ) ;
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 ? 'nn```' : '' ) ) ;更多其它細節請看代碼,如果對代碼有疑問的,請加我微信(同GitHub id)
BSD 2-Clause