最近在经营养斗鱼的事情,感觉和鱼友交流的压力有点繁重。
这不巧了吗,有Deepseek(下文简称DS)这个好东西。于是就萌生搞个带AI顾问的公众号的想法。

QQ截图20250211190007.png

https://www.bilibili.com/video/BV1fjNmeNEXS

其实技术上的实现难度几乎没有,手搓代码可能也就1、2小时能完成。但我还是选择了相信DS,口述了我的需求,让它来完成代码的部分。结果而言完成的很好。以下是流水账和遇到的一些问题和解决思路。

准备

  1. 需要一个DS的 APIKey, 我是上官网开的,第三方现在很多选择了,只要是满血的671b模型的表现应该都是一致的。
  2. https://mp.weixin.qq.com/ 注册一个公众号(以前叫订阅号)。注册为个人号,要求的门槛最低,但功能也是最少限制最多的。这引致了后面的一点小问题。
  3. 自备一个服务器(可以是云主机、虚拟主机、云应用),有已备案域名,能跑 PHP/MySQL 就行。

思路

部署一个常规的微信公众号响应脚本(用户发消息,接收后被动回复),调用SD 的 API 生成回复内容。

实施

公众号的注册和开发配置过程略过,按照微信文档帮助处理即可。验证开发设置URL直接用微信提供的PHP demo配置上自己的 appId appKey即可

响应公众号的php脚本:

<?php
/**
  * wechat php 
  * 
  * 公众号的响应脚本
  * 所在路径
  * 
  */
//no cache
header('Cache-Control:no-cache,must-revalidate');  
header('Pragma:no-cache');  

//define your token
define("TOKEN", "your_token");

$wechatObj = new wechatCallbackapiTest();
if(isset($_GET["echostr"])){
    $wechatObj->valid();
}else{
  $wechatObj->responseMsg();  
}


class wechatCallbackapiTest
{
    
    private $err = "init";
    
    public function error(){
        return $this->err;
    }
    
   public function valid()
    {
        $echoStr = $_GET["echostr"];

        //验证微信响应脚本URL
        //valid signature , option
        if($this->checkSignature()){
            echo $echoStr;            
            exit;
        }
    }

    public function responseMsg()
    {
        $postStr = file_get_contents('php://input', 'r');
        
        
          //extract post data
        if (!empty($postStr)){
                
          $postObj = simplexml_load_string($postStr, 'SimpleXMLElement', LIBXML_NOCDATA);
                
                $msgType = $postObj->MsgType;
                $event = $postObj->Event;
                $fromUsername = $postObj->FromUserName;
                $toUsername = $postObj->ToUserName;
                $keyword = trim($postObj->Content); //只有text类型消息才会有Content                
                
                $time = time();
                $textTpl = "<xml>
            <ToUserName><![CDATA[%s]]></ToUserName>
            <FromUserName><![CDATA[%s]]></FromUserName>
            <CreateTime>%s</CreateTime>
            <MsgType><![CDATA[%s]]></MsgType>
            <Content><![CDATA[%s]]></Content>
            <FuncFlag>0</FuncFlag>
        </xml>";             
        if(!empty( $keyword )) //关键词答复
                {
                    $key = strtolower($keyword);
                    switch($key){
                        case 'hi':
                            die($this->xmlText($fromUsername,$toUsername,MSG_WELCOME));
                        break;
                        case 'tips':
                        case '提示':
                            $msg = "欢迎,这里是斗鱼繁育小助手(已接入Deepseek),可以查询饲养斗鱼的常见问题。 
输入【提示】,显示本消息。你还可以尝试以下其他关键词或直接提问相关的问题。

【新鱼到家】【养水】【混养】【过冬】【常见品种】【常见疾病】【如何繁殖】【鱼苗开口】
                            ";
                            die($this->xmlText($fromUsername,$toUsername,$msg));
                            break;
                        case '新鱼到家': //普通的关键词词典命中直接返回预设答案
                            $msg = "小鱼刚到家时,由于路途颠簸和不熟悉新环境及温度,可能会出现颜色变浅的现象,这是正常情况,通常适应1天后颜色会逐渐恢复。建议过温过水再入缸。为了帮助小鱼缓解应激,建议在到家第一天不要投喂,并将其放置在安静、光线柔和或偏暗的环境中。";
                            die($this->xmlText($fromUsername,$toUsername,$msg));
                        break;
                        
                        default: //其他问题 交给 AI尝试回答
                            //注意事项,由于AI响应较慢,微信要求5秒内回应,不回应则则重试两次。
                            
                            //1.检查db是否已经具有此答案
                            $s = $this->db();
                            $sql = "SELECT `reply`,`createtime` FROM `chatlog` WHERE `ask` = '".$s->escape($keyword)."' ORDER BY `id` DESC LIMIT 1";
                            $rs = $s->getLine($sql);

                            if($rs){ 
                                //超过一个小时还未得到答案
                                if(!$rs["reply"] && time() > (strtotime($rs["createtime"]) + 3600)){
                                    // 定期清理超过一定时间未回答的记录
                                    $sql = "DELETE FROM `chatlog` WHERE `reply` = '' AND `createtime` < DATE_SUB(NOW(), INTERVAL 1 HOUR)";
                                    $s->runSql($sql);
                                    unset($rs);
                                }
                            }

                            if($rs){
                                //可能上一个请求还在处理,尚未有答案,轮询等待
                                $waitcount = 0; 
                                while($waitcount < 3 && !$rs["reply"]){
                                    $waitcount++;
                                    sleep(1);
                                    $rs = $s->getLine($sql);
                                }
                                if($rs["reply"]){ //db 已有答案                                    

                                    $feedback = $rs["reply"];
                                    die($this->xmlText($fromUsername,$toUsername, $feedback));

                                }else{ //本轮查询还未得到答案,放弃回答                                    
                                    die($this->xmlText($fromUsername,$toUsername, "AI仍在思考中,1分钟后再复制黏贴问这个问题试试。"));
                                }

                            }else{ //没有这个问题
                                // 新问题,记录到数据库(初始无回答)
                                $sql = "INSERT INTO `chatlog`(`ask`, `reply`) VALUES (
                                    '".$s->escape($keyword)."',
                                    ''
                                )";
                                $s->runSql($sql);
                                $qaid = $s->lastId();

                                $start_time = microtime(true); // 记录开始时间
                                $feedback = $this->ask_deepseek($keyword); // 发起API请求
                                $elapsed_time = microtime(true) - $start_time; // 计算耗时
                                //大概率首次回答会被微信后台抛弃重试,只需要专注于更新db

                                if($feedback == '0'){
                                    $feedback = "问题【{$keyword}】似乎与斗鱼饲养繁殖无关,其它问题推荐到微信或抖音粉丝群与其他网友交流。"; 
                                }elseif(strstr($feedback,"err:")){ //接口错误
                                    //写入日志
                                    file_put_contents("api.log", "\n[API ERROR] ".feedback, FILE_APPEND);
                                }else{} //无异常
                                
                                //写库                  
                                // 更新数据库中的回答
                                $sql = "UPDATE `chatlog` SET `reply` = '".$s->escape($feedback)."' WHERE `id` = {$qaid} LIMIT 1;";
                                $s->runSql($sql);

                                die($this->xmlText($fromUsername,$toUsername, $feedback));
                            }
                        break;
                    }
                }else{
                    
                    //处理关注事件
                    if($event=="subscribe"){ //关注
                        $msg = "欢迎,这里是斗鱼繁育小助手(已接入Deepseek),可以查询饲养斗鱼的常见问题。 
输入【提示】,显示本消息。你还可以尝试以下其他关键词或直接提问相关的问题。

【新鱼到家】【养水】【混养】【过冬】【常见品种】【常见疾病】【如何繁殖】【鱼苗开口】";                    
                        die($this->xmlText($fromUsername,$toUsername,$msg));
                    }else{ //debug                                        
                        die($this->xmlText($fromUsername,$toUsername,"event push:".$event."\n".$postStr)); //尝试原样返回为支持的时间raw信息
                    } 
                    
                    
                    echo "";
                }

        }else {
            
            echo "well? empty request";
            exit;
        }
    }
        
    private function checkSignature()
    {
        $signature = $_GET["signature"];
        $timestamp = $_GET["timestamp"];
        $nonce = $_GET["nonce"];    
                
        $token = TOKEN;
        $tmpArr = array($token, $timestamp, $nonce);
        sort($tmpArr,SORT_STRING);
        $tmpStr = implode( $tmpArr );
        $tmpStr = sha1( $tmpStr );
        
        //debug
        file_put_contents('wx.access.log',date("\nY-m-d H:i:s - ")."(_GET)".print_r($_GET,true),FILE_APPEND);
        file_put_contents('wx.access.log',date("\nY-m-d H:i:s - ")."(checkSignature){$tmpStr}:{$signature}",FILE_APPEND);
        
        if( $tmpStr == $signature ){
            return true;
        }else{
            return false;
        }
    }
    
    
    /**
     * wechatCallbackapiTest::xmlText()
     * 组织文本消息 xml
     * @param String $fromUsername
     * @param String $toUsername
     * @param String $contentStr
     * @return String $xml
     */
    private function xmlText($fromUsername,$toUsername,$contentStr){
        $time = time();
        $textTpl = "<xml>
                            <ToUserName><![CDATA[%s]]></ToUserName>
                            <FromUserName><![CDATA[%s]]></FromUserName>
                            <CreateTime>%s</CreateTime>
                            <MsgType><![CDATA[%s]]></MsgType>
                            <Content><![CDATA[%s]]></Content>
                            <FuncFlag>0</FuncFlag>
                            </xml>";  
       
        $msgType = "text";
        $resultStr = sprintf($textTpl, $fromUsername, $toUsername, $time, $msgType, $contentStr);
           return $resultStr;                             
    }
    
    
    private function ask_deepseek($question) {
        $api_key = 'sk-{你的deepseek API Keys}'; // 替换为你的API密钥
        $api_url = 'https://api.deepseek.com/chat/completions'; // 确认实际API地址
    
        // 系统角色设定(关键部分)
        $system_message = <<<PROMPT
你是一个专门解答泰国斗鱼养殖问题的百科全书AI助手,包括以下范畴:
1. 泰国斗鱼的饲养方法
2. 鱼缸设备与水质管理
3. 鱼类疾病防治
4. 繁殖技术与品种改良
5. 水族造景与生态系统维护

请严格遵守以下规则:
1. 如果问题超出以上范畴,直接返回数字0,不要任何解释或附加内容。
2. 即使问题涉及其他水生生物(如龟类、水草)但无关鱼类养殖也返回0。
3. **回答内容必须为纯文本格式,禁止使用任何Markdown标签(如 ```、**、*、# 等)**。
4. 确保回答内容简洁、清晰,适合直接显示在文本消息中。

重要提示:回答内容必须为纯文本格式,禁止使用任何形式的 Markdown 标签或特殊符号。
PROMPT;
    
        // 构造请求数据
        $data = [
            'model' => 'deepseek-chat', // 确认实际模型名称
            'messages' => [
                ['role' => 'system', 'content' => $system_message],
                ['role' => 'user', 'content' => $question]
            ],
            'max_tokens' => 2000,
            'temperature' => 0.3
        ];
    
        // 发送API请求
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL => $api_url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_POST => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
                'Authorization: Bearer ' . $api_key
            ],
            CURLOPT_POSTFIELDS => json_encode($data),
            CURLOPT_TIMEOUT => 45, // 设置总超时时间为45秒
            CURLOPT_CONNECTTIMEOUT => 10 // 设置连接超时时间为10秒
        ]);
    
        $response = curl_exec($ch);
        
        if (curl_errno($ch)) {
            $error_msg = curl_error($ch);
            curl_close($ch);
            return 'err:API请求错误: ' . $error_msg;
        }
        curl_close($ch);
    
        // 解析响应
        $result = json_decode($response, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            return 'err:API响应解析错误';
        }
    
        //$answer = $result['choices'][0]['message']['content'] ?? '';//?
        //debug
        if (isset($result['choices'][0]['message']['content'])) {
            $answer = $result['choices'][0]['message']['content'];
        } else {
            file_put_contents("api.log", "\n[STRUCTURE ERROR] ".print_r($result,true), FILE_APPEND);
        }
    
        // 严格检测0响应        
        
        //手动清除markdown标签
        $patterns = [
            '/\*\*(.*?)\*\*/' => '$1', // 加粗
            '/\*(.*?)\*/' => '$1',     // 斜体
            '/`(.*?)`/' => '$1',       // 代码块
            '/#+\s*/' => '',           // 标题
            '/\[(.*?)\]\(.*?\)/' => '$1', // 链接
        ];
        $answer = preg_replace(array_keys($patterns), array_values($patterns), $answer);
        return (trim($answer) === '0') ? '0' : $answer;
    }
    
    
    
    //数据库连接
    private function db(){
        if(isset($this->s)){
            return $this->s;
        }else{
            include_once("./fmysql.class.php");
            $this->s = new FMYSQL();
            return $this->s; 
        }
    }
    
}

?>

代码有点长,而且上线调试的时候遭遇了一点问题进行了调整优化,我们拆解来看

private function ask_deepseek($question) 是调用DS API的用法,它会在上文微信用户发来的消息没有匹配到静态答案的时候触发。

遇到的问题&优化

最初的代码没有这么复杂,但遇到了一个矛盾:
微信平台要求响程序本必须 5秒内响应,如果5秒未能输出结果,会主动断开连接然后再次重试,最多重试3次。
但deepseek在大部分情况下是需要一些时间思考后才能输出答案的。实测下来通常这个时间需要10-30秒左右。

按照正常的思路去解决这个问题,应该是做异步处理。用另一个微信公众号接口:客服消息。这个接口允许在用户向公众号提问的48小时内,程序主动向用户发消息,但是!这个接口要求公众号进行企业认证不对个人号开放,堵死了这个方向的解决途径。那么迂回地解决这个问题,重新思考,利用响应机制的5秒重试。来实现异步处理。

同时,使用数据库MySQL 建一个表存储AI对话的问题和答案,作用有三。一方面是作为问题的回答进度查询,另一方面是缓存答案,避免微信端重试的时候反复查询同一个问题,节省Token消耗,最后一方面是记录历史的问题和答案以便跟踪调优AI的调用。

-- 表的结构 `chatlog`
--

CREATE TABLE `chatlog` (
  `id` int(11) NOT NULL,
  `ask` varchar(100) NOT NULL,
  `reply` text NOT NULL,
  `createtime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

--
-- Indexes for table `chatlog`
--
ALTER TABLE `chatlog`
  ADD PRIMARY KEY (`id`),
  ADD UNIQUE KEY `ask` (`ask`);

--
-- 使用表AUTO_INCREMENT `chatlog`
--
ALTER TABLE `chatlog`
  MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
COMMIT;

上文代码引用了一个fmysql.class的数据库类简化代码,没有的话自己替换成常规mysql处理语句替代 $s->getLine()$s->runSql() 即可

这个变通的异步机制如下。

用户在微信公众号向AI提问 =>
微信服务器首次请求 => 触发 ask_deepseek() 调用API向AI求助,并开始计时。
=> 此时先把问题写入数据库。
=> AI思考需要时间,这个时间大概率会超过5秒。链接被微信后台断开,但程序会继续执行把得到的答案写入数据库。

微信服务器第二次 => 查询数据库,发现这个问题已经存在。如果 reply 为空,表示AI还未返回答案,隔1秒查询一次,5秒内还未得到答案,响应微信,告知用户:AI还在思考;

当用户1分钟后再次提问相同问题或微信服务器再次查询时已有答案,则把答案返回给用户。

感想

在AI普及的未来,你不一定要掌握具体的技巧和经验,但一定要对这个世界保持谦卑、好奇确保脑子一直转动有用不完的想法。

附带的彩蛋

让AI又写了个直接调用查看AI对话历史的页面

https://2.ruoyuzhilian.site/douyubot/

相关链接

标签: 微信公众号, AI, deepseek

添加新评论