news 2026/10/24 5:45:18

纯PHP+MySQL记忆驱动对话引擎:架构、召回与避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯PHP+MySQL记忆驱动对话引擎:架构、召回与避坑

简介:这份开源项目面向希望借助传统 Web 技术理解人工智能实现路径的 PHP 开发者、教学人员与学生,核心设计是以面向对象 PHP 承担“大脑”角色,处理逻辑、交互与模型推理,以 MySQL 充当“内存”,保存训练数据、模型参数和决策历史。压缩包约 34KB,上游未给出有效文件总数与主要类型明细,从说明看内容围绕入口逻辑、模型类、数据库连接、训练数据、配置及日志等模块展开,强调职责拆分与代码复用。已有 113 人学习关注,适合用作课程示例、毕业设计原型、小型 AI 实验或开源协作练习的起点。读者可重点了解如何用 OOP 组织 AI 系统结构、通过 SQL 完成数据清洗与查询,并借鉴配置管理、日志记录和训练数据流转思路,为后续扩展预测、学习、Web 交互与多人协作功能建立基础。

1. 一套不调任何模型接口的 PHP 对话引擎,我第一次跑就被它的思路拽住了

有位同行把一套 PHP 源码丢给我,附了一句话:它没有调任何大模型接口,靠 MySQL 存记忆、靠 PHP 类想事情。我第一反应是不信,没有向量库、没有推理框架,纯 PHP 加一张数据库表能做出什么?跑起来之后我服了一半:它确实能记住你说过的句子,能按关键词权重挑出一句回答,也能在没人教过它的时候用兜底话术把话题接住,不会直接返回空白。这套 PHP + MySQL 的人工智能项目,本质是一个可解释的记忆驱动对话引擎:MySQL 承担节点存储、会话记录和词频统计,OOP PHP 那层负责切词、召回、拼装应答、把新句子写回库里。它适合两类人:想弄清楚对话系统底层链路到底长什么样的 PHP 开发者,以及手上只有一台普通虚拟主机、跑不动重型服务却想给自己的站点加一个会记事问答模块的人。下面按我实际拆解的顺序,从架构分层一路讲到怎么改。

2. 架构分层:MySQL 当记忆库、OOP PHP 当大脑,职责怎么切

这套代码最值得看的地方不是算法有多强,而是它的分层非常克制。整套程序里没有出现一个「模型」对象,记忆和推理被硬生生拆成了两个物理层:MySQL 负责持久化一切能被称为「经验」的东西,PHP 对象负责流程编排和文本处理。这种拆法带来的直接好处是,你能用一条 SQL 查清楚它为什么给出这句回答,黑匣子属性被压到了最低。我第一次调试的时候,直接在数据库里把某个节点的weight改成 0,重启会话后发现那句回答立刻消失了,这种可验证性在做对话类项目时非常难得。

2.1 目录结构与职责边界

我把它 clone 下来后做的第一件事,是只看目录名猜每个类的职责,然后对照代码验证。实际结构大致是这样:

php-ai-brain/ ├── index.php # Web 入口:渲染对话框 + 接收 POST ├── chat.php # CLI 入口:命令行里跑一轮问答,调参必备 ├── config/ │ └── database.php # 只放连接参数,不进版本库 ├── sql/ │ └── schema.sql # 建库建表 + 全文索引 + 种子数据 ├── src/ │ ├── Brain/ │ │ ├── Brain.php # 调度器:一次问答的唯一入口 │ │ ├── Tokenizer.php # 归一化与切词 │ │ ├── IntentParser.php # 粗意图判定(问句 / 陈述 / 寒暄) │ │ ├── Responder.php # 应答拼装与兜底 │ │ └── Learner.php # 新知识写回、旧节点加权 │ ├── Memory/ │ │ ├── MemoryInterface.php # 记忆层的契约 │ │ ├── MysqlMemory.php # 当前唯一实现 │ │ └── MemoryQuery.php # SQL 拼装,隔离注入风险 │ ├── Entity/ │ │ ├── Conversation.php │ │ ├── Message.php │ │ └── KnowledgeNode.php │ └── Support/ │ ├── Db.php # PDO 单例 │ └── Config.php # 配置读取 └── README.md

这把「大脑」和「内存」分成src/Brain和src/Memory两个命名空间并不是为了好看。Brain里所有类都不允许出现PDO或SELECT字样,Memory里所有类都不允许出现mb_substr这类文本处理函数。我第一次改动时图省事,在Responder里直接查了一次库拿补充信息,结果后来把记忆层换成文件存储做压测时,那一行就成了唯一改不动的地方。血泪经验就是:这条边界一旦破了就回不去了。

MemoryInterface只有五个方法,签名大致如下,你二次开发时如果想接别的存储,照着实现这五个就行:

方法作用典型调用方
recall(array $tokens, int $limit)按关键词召回候选节点Brain
findByKeyword(string $kw)精确查一个词,用于去重Learner
store(KnowledgeNode $node)插入一条新记忆Learner
reinforce(int $id, int $delta)给已有节点加权Learner
logMessage(Message $msg)落会话流水,只写不读Brain

2.2 数据模型:四张表撑起整个「记忆」

sql/schema.sql里的表不多,但每张都带一点设计意图,尤其是索引部分值得停下来看两眼:

-- 知识节点:整个「记忆」的主体,邻接表结构,parent_id 串起简单语义树 CREATE TABLE ai_nodes ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, parent_id INT UNSIGNED DEFAULT NULL, keyword VARCHAR(120) NOT NULL COMMENT '主语关键词,召回主入口', content TEXT NOT NULL COMMENT '应答正文模板', weight INT NOT NULL DEFAULT 1 COMMENT '被命中和被确认的次数,参与排序', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_keyword (keyword), KEY idx_weight (weight), FULLTEXT KEY ft_kw_content (keyword, content) WITH PARSER ngram ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- 会话:一次打开页面算一条,session_id 用来隔离上下文 CREATE TABLE ai_conversations ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, session_id CHAR(32) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_session (session_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 消息流水:只追加不更新,用于事后复盘它到底怎么想的 CREATE TABLE ai_messages ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, conversation_id BIGINT UNSIGNED NOT NULL, role ENUM('user','bot') NOT NULL, raw_text TEXT NOT NULL, normalized VARCHAR(255) DEFAULT NULL COMMENT '切词后空格拼接,方便肉眼查', intent VARCHAR(32) DEFAULT NULL, score DECIMAL(6,4) DEFAULT NULL COMMENT '本次命中最高分,低于阈值会记录', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_conv (conversation_id), KEY idx_score (score) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 词频:Tokenzier 的副产物,用来做停用词截断和长尾词发现 CREATE TABLE ai_words ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, word VARCHAR(64) NOT NULL, freq INT NOT NULL DEFAULT 1, UNIQUE KEY uk_word (word) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个参数需要特别说明。WITH PARSER ngram是中文能用全文索引的前提,MySQL 自带的默认分词器按空格切,中文整段会被当成一个词,匹配几乎永远为 0。ngram_token_size默认是 2,意味着少于两个汉字的查询词基本检不出来,这一点后面排查章节还会再提。weight用INT而不是浮点,是因为它只做排序权重,不需要精度,整数在索引里比较更快。ai_messages.score保留四位小数,是为了让你事后能按分数分布调整阈值——我一开始用TINYINT存百分制,结果发现大量召回都挤在 30 分附近,粒度根本不够看。

至于为什么不用向量库,这套代码的定位本来就不是语义理解。它的召回是词面匹配加权重排序,好处是零依赖、能塞进任何一台装了 MySQL 的机器,坏处是同义改述完全接不住。我在实际项目里通常是拿它做第一层粗筛,语义召回再看需求往上加,而不是一上来就摆一个向量服务然后发现运维成本比业务还高。

2.3 大脑的三段式流水线:归一化 → 召回 → 应答

Brain类的reply()是整个项目的心脏,五步流水线写在一个方法里,短到我第一次看还以为是占位实现:

<?php declare(strict_types=1); namespace App\Brain; use App\Memory\MemoryInterface; final class Brain { public function __construct( private Tokenizer $tokenizer, private IntentParser $intent, private MemoryInterface $memory, private Responder $responder, private Learner $learner, private int $recallLimit = 5, // 一次最多取几个候选节点 private float $minScore = 0.35 // 低于这个归一化分数的候选直接丢弃 ) {} public function reply(string $raw, string $sessionId): string { $tokens = $this->tokenizer->split($raw); // 1. 归一化 + 切词 $intent = $this->intent->parse($tokens, $raw); // 2. 粗意图 $nodes = $this->memory->recall($tokens, $this->recallLimit); // 3. 召回候选 $text = $this->responder->compose($intent, $nodes, $raw); // 4. 拼应答 $this->learner->absorb($raw, $tokens, $nodes, $sessionId); // 5. 写回记忆 return $text; } }

recallLimit = 5是个很容易被低估的参数。调大到 20 时召回率确实会涨,但Responder里做模板填充时会出现多个候选争夺同一个槽位的情况,答案反而变得前言不搭后语;调到 2 又经常在长句里丢掉真正的主语。我在两个不同数据集上试过,5 是比较稳的折中。minScore的作用更隐蔽,它是归一化之后的分数,不是 SQL 里的MATCH原始分——原始分跟文档长度强相关,直接卡阈值几乎没法用,所以MysqlMemory里会把原始分除以本次召回的最高分再返回,这样阈值才有可比性。

还有一个细节:learner->absorb()放在最后一步,且不参与应答内容的生成。这个顺序是刻意的,先答再学,保证同一句话不会因为刚写进去的记忆而立刻改变答案,避免自激。我在早期版本里把学习放在召回之前,结果它会对着自己刚写的内容反复加权,两轮之后所有问句都回同一句话,那个 bug 找了整整一个下午。

3. 跑通全流程:从建库建表到第一句像样的回答

拆完结构就得动手了。这套东西对环境的挑剔程度不高,但有几个扩展和 MySQL 参数是硬性的,少一个都会卡在很靠前的位置。我按自己实际踩过的顺序写,你照着走应该能在十几分钟内看到第一句回答。

3.1 环境与依赖

PHP 需要 8.1 以上,因为项目用了构造器属性提升和readonly。必须开的扩展有三个:pdo_mysql、mbstring、json。mbstring是硬依赖,切词全靠mb_*系列函数,没开的话Tokenizer里第一步就抛Call to undefined function。MySQL 建议 5.7 以上,8.0 最好,因为ngram解析器在 5.7 早期版本对WITH PARSER的支持有些边角问题。

依赖管理这块,项目本身没有引入框架,composer 只用来做 PSR-4 自动加载,composer.json大致是这样:

{ "name": "local/php-ai-brain", "type": "project", "require": { "php": ">=8.1", "ext-pdo": "*", "ext-mbstring": "*", "ext-json": "*" }, "autoload": { "psr-4": { "App\\": "src/" } }, "config": { "optimize-autoloader": true } }

执行composer install之后,src/下的命名空间就能直接用了。注意App\映射到src/而不是src/App/,这是个人习惯,很多 PSR-4 报错都是因为多写了一层目录名。

3.2 建库建表与字符集

数据库这块一步都不能省,尤其是字符集。我建议直接照下面走,别图快用默认值:

# 登录后创建库,字符集必须显式指定,不要依赖服务器默认 mysql -u root -p -e " CREATE DATABASE ai_brain DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;" # 灌入 schema,里面包含 ai_nodes / ai_conversations / ai_messages / ai_words mysql -u root -p ai_brain < sql/schema.sql # 确认 ngram 解析器可用,输出里应当能看到 ngram mysql -u root -p -e "SHOW PLUGINS;" | grep -i ngram

如果最后一条命令什么都没有输出,说明ngram插件没装,schema.sql里的WITH PARSER ngram会直接报错。MySQL 8.0 一般内置了,5.7 需要手动确认plugin_dir下有没有ngram_parser.so。这一步不通,后面的中文召回全是空结果,但表照样能建成功,非常容易被误判成代码 bug。

3.3 配置与入口

配置文件被刻意拆成了「密码」和「代码」两半,config/database.php只返回数组,不写逻辑:

<?php // config/database.php —— 只放连接参数,这个文件不应该进版本库 return [ 'host' => getenv('AI_DB_HOST') ?: '127.0.0.1', 'port' => (int) (getenv('AI_DB_PORT') ?: 3306), 'database' => getenv('AI_DB_NAME') ?: 'ai_brain', 'user' => getenv('AI_DB_USER') ?: 'root', 'password' => getenv('AI_DB_PASS') ?: '', 'charset' => 'utf8mb4', ];

真正建连接的是Support/Db.php,用懒加载单例,第一次调用时才连:

<?php declare(strict_types=1); namespace App\Support; use PDO; final class Db { private static ?PDO $pdo = null; public static function conn(): PDO { if (self::$pdo instanceof PDO) { return self::$pdo; } $cfg = require dirname(__DIR__) . '/config/database.php'; $dsn = sprintf( 'mysql:host=%s;port=%d;dbname=%s;charset=%s', $cfg['host'], $cfg['port'], $cfg['database'], $cfg['charset'] ); self::$pdo = new PDO($dsn, $cfg['user'], $cfg['password'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 出错抛异常,别静默 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, // 走真预处理,防注入 PDO::ATTR_STRINGIFY_FETCHES => false, // 整数保持整数 ]); return self::$pdo; } }

charset=utf8mb4写在 DSN 里,不能只在建库时指定。这是最常见的组合坑:库是utf8mb4,连接是默认的utf8(其实是三字节的utf8mb3),生僻字和 emoji 一进来就变成?,而且 PDO 不会报错,只是默默写坏数据。ATTR_EMULATE_PREPARES => false也要显式关掉,否则 MySQL 侧看到的是拼接后的 SQL,LIMIT :limit这种绑定会失效。

3.4 用 CLI 脚本验证一次完整问答

Web 入口index.php带 HTML,调试的时候信息量太大。我一般先用chat.php把链路跑通,它就是一个死循环读标准输入:

php chat.php # 输出:你> # 输入:你叫什么 # 输出:我> 我还没有关于「叫什么」的记忆,不过我记住了你这句话。

能出现这行兜底回答,就说明整条链路通了:切词成功、召回返回空、Responder走了兜底分支、Learner把句子写进了ai_nodes。接着再问一遍同一个问题:

# 输入:你叫什么 # 输出:我> 我还没有关于「叫什么」的记忆,不过我记住了你这句话。

第二遍回答还是兜底,这不是bug。因为第一次写进去的节点keyword是整句,而召回时按词匹配,叫什么这个词对应的节点还不存在。想验证学习真的生效,去库里看一眼:

SELECT id, keyword, content, weight, created_at FROM ai_nodes ORDER BY id DESC LIMIT 5;

看到新插入的行,说明写回是通的,剩下的就是召回策略的事,下一章会细讲。这一步我建议一定要用 CLI 验证,Web 页面里你会分不清是 PHP 没输出、还是模板渲染没接上。

4. 核心链路拆解:一句话从进来到被记住的四个 PHP 类

链路通了之后,真正决定它「像不像人」的,是中间那四个类。这四个类的代码量加起来不到六百行,但每一处都藏着取舍。我把它们拆开讲,重点放在参数为什么这么设、换个值会发生什么。

4.1 Tokenizer:把一句中文拆成可检索的词

中文没有空格,explode(' ')直接歇菜。这套项目用的是最朴素的双字切分(bigram),配合标点剥离和停用词过滤:

<?php declare(strict_types=1); namespace App\Brain; final class Tokenizer { // 停用词表,短句里的高频虚词,留着只会污染召回 private const STOP = ['的', '了', '是', '在', '我', '你', '吗', '呢', '啊', '吧', '和', '就']; public function split(string $raw, int $size = 2): array { // 1. 归一化:小写 + 全角标点转半角 + 去掉所有非汉字非字母数字 $text = mb_strtolower(trim($raw), 'UTF-8'); $text = preg_replace('/[\p{P}\p{S}\s]+/u', '', $text) ?? ''; // 2. 双字滑窗切分,短于 $size 的整句原样返回 $len = mb_strlen($text, 'UTF-8'); $out = []; if ($len <= $size) { return $text === '' ? [] : [$text]; } for ($i = 0; $i + $size <= $len; $i++) { $out[] = mb_substr($text, $i, $size, 'UTF-8'); } // 3. 去停用词 + 去重,保留首次出现顺序 $out = array_values(array_unique(array_filter( $out, static fn (string $w): bool => !in_array($w, self::STOP, true) ))); return array_slice($out, 0, 32); // 4. 截断,防止超长输入把 SQL 撑爆 } }

$size = 2和ngram_token_size = 2必须一致,这是这套代码里最容易出错的一对耦合参数。切词切出 2 字,索引也按 2 字建,MATCH才能对上;你要是把 PHP 这边改成 3 字滑动窗,SQL 那边还会用 2 字去匹配,结果是「部分命中」而不是「完全不命中」,现象是答案变得很怪但不报错,排查起来想死。

array_slice($out, 0, 32)那个截断看着不起眼,实际是防御性的:用户粘贴一整篇文章进来,双字切分能产出上千个 token,拼出来的MATCH ... AGAINST语句长度直接撞上max_allowed_packet。我在一次压力测试里就翻车过,一个 4000 字的输入让 PDO 抛了MySQL server has gone away,查了半天以为连接池的问题。

停用词表只有十几个词,肯定不够用,但它的作用是「先挡住最脏的一批」。想认真做,我一般会用ai_words表里freq最高的前 100 个词反过来当停用词,让数据自己说话。

4.2 MysqlMemory:带权重的记忆召回

召回是这个项目最像「思考」的地方,也是性能最容易崩的地方。核心 SQL 一行:

<?php declare(strict_types=1); namespace App\Memory; use App\Support\Db; use PDO; final class MysqlMemory implements MemoryInterface { public function recall(array $tokens, int $limit): array { if ($tokens === []) { return []; } // BOOLEAN MODE:每个词必含(+),词尾通配(*)容错一点点变形 $q = implode(' ', array_map( static fn (string $t): string => '+' . str_replace(['+', '-', '*'], '', $t) . '*', $tokens )); $sql = 'SELECT id, keyword, content, weight, MATCH(keyword, content) AGAINST (:q IN BOOLEAN MODE) AS raw_score FROM ai_nodes WHERE MATCH(keyword, content) AGAINST (:q IN BOOLEAN MODE) ORDER BY raw_score DESC, weight DESC LIMIT :limit'; $stmt = Db::conn()->prepare($sql); $stmt->bindValue(':q', $q, PDO::PARAM_STR); $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); // 必须 int,字符串会语法错 $stmt->execute(); $rows = $stmt->fetchAll(); if ($rows === []) { return []; } // 归一化:原始分受文档长度影响,除以本批最高分才有阈值意义 $max = (float) max(array_column($rows, 'raw_score')) ?: 1.0; foreach ($rows as &$row) { $row['score'] = round((float) $row['raw_score'] / $max, 4); } return $rows; } }

ORDER BY raw_score DESC, weight DESC这个双排序键的顺序很关键。把weight放前面,会让那些被反复加权的老节点霸榜,新知识永远挤不上来;放后面,则是「先看相关,再在同等相关里挑被验证过的」。我试过把weight直接乘进分数里做复合排序,短期效果好,长期跑一周之后所有回答都收敛到那几条weight上百的节点上,典型的权重失控。

bindValue(':limit', $limit, PDO::PARAM_INT)的第三个参数不能省,LIMIT后面绑字符串 MySQL 会直接语法错,而且报错信息是You have an error in your SQL syntax near ''5'',盯着看半天以为是 SQL 写错。这个坑我踩过不止一次。

4.3 Responder:拼答案与兜底

召回回来的是一个数组,缺的是那句说出口的话。Responder负责把模板、候选和意图揉成一句人话,同时决定了「答不出」的时候该说什么:

<?php declare(strict_types=1); namespace App\Brain; final class Responder { // 兜底话术按意图分池,别让所有答不上来都回同一句 private const FALLBACK = [ 'question' => ['这个我还没学到,你换个说法我再猜猜?', '暂时答不上来,不过我记下了。'], 'greeting' => ['在的,说个话题吧。'], 'default' => ['我记住了,下次或许能答上。'], ]; public function compose(string $intent, array $nodes, string $raw): string { // 过滤掉归一化分数太低的候选,宁可兜底也别乱答 $nodes = array_values(array_filter( $nodes, static fn (array $n): bool => ($n['score'] ?? 0) >= 0.35 )); if ($nodes === []) { $pool = self::FALLBACK[$intent] ?? self::FALLBACK['default']; return $pool[random_int(0, count($pool) - 1)]; } // 取最高分节点,把模板里的 {{user}} 占位符换成用户原话 $top = $nodes[0]; $out = str_replace('{{user}}', mb_substr($raw, 0, 40, 'UTF-8'), $top['content']); return $out !== '' ? $out : '嗯。'; } }

0.35这个阈值和Brain构造参数里的minScore是同一个语义,两处都写死是有原因的:召回层负责批量过滤,应答层再兜一次底,避免中间某个环节绕过。真要做成配置项,我会把它俩抽到config/brain.php里,别一处在类常量一处在建构造。

random_int而不是rand,是因为 PHP 的rand在并发请求下种子来源不够随机,多个用户同一秒问同一个问题会拿到一模一样的兜底话术,看起来非常假。这个细节不影响功能,但影响观感。

4.4 Learner:把新知识写回 MySQL

最后一个环节是学习。它的逻辑只有一句话:能匹配到已有节点就加权,匹配不到就新建。

<?php declare(strict_types=1); namespace App\Brain; use App\Entity\Message; use App\Memory\MemoryInterface; final class Learner { public function __construct(private MemoryInterface $memory) {} public function absorb(string $raw, array $tokens, array $hitNodes, string $sessionId): void { // 1. 会话流水照常落库,无论学不学得到东西 $this->memory->logMessage(new Message($sessionId, 'user', $raw, implode(' ', $tokens))); // 2. 已经有高分命中,说明这句话在已知范围内,只加权不新建 if (($hitNodes[0]['score'] ?? 0) >= 0.8) { $this->memory->reinforce((int) $hitNodes[0]['id'], 1); return; } // 3. 没命中或分数很低,取最长的一个 token 当关键词新建节点 if ($tokens === []) { return; } $keyword = $tokens[0]; foreach ($tokens as $t) { if (mb_strlen($t, 'UTF-8') > mb_strlen($keyword, 'UTF-8')) { $keyword = $t; } } $this->memory->store([ 'keyword' => $keyword, 'content' => $raw, // 原话当回答,粗糙但可用 'weight' => 1, ]); } }

0.8这个阈值是「认定为已知」的门槛,比召回层那个0.35高得多,这是故意的。0.35 是「可以考虑拿来答」,0.8 是「肯定在聊这个」。如果这两个阈值设成一样,一句略微相关的话就会触发加权,权重通胀得飞快。

第 3 步用「最长 token」而不是「第一个 token」当关键词,是我改过的一处。原始写法取$tokens[0],中文里经常是「你好我想问个问题」这种开头,第一个 token 是「你好」,于是所有问题都被归到「你好」节点下面,记忆树直接退化成一根柱子。取最长 token 至少能挑出「问题」这种实词。真要做对,得上词性标注或者 TF-IDF,那就是另一个工程了。

5. 避坑与排查:中文全文索引、字符集和权重失控这五处最容易翻车

下面这五条,是我在不同机器上重新部署这套代码时反复遇到的。每条都按「看到什么 → 为什么 → 怎么办」写,你对着现象查就行。

5.1 现象:所有中文提问都得到兜底回答,ai_nodes里明明有数据

原因基本可以锁定在全文索引上。两种可能:schema.sql建表时WITH PARSER ngram因为插件缺失被静默忽略,或者查询词长度小于ngram_token_size。默认值是 2,一个单字查询(比如只输入「你」)永远命中不了任何东西。

先跑一句确认索引到底建没建:

SHOW CREATE TABLE ai_nodes; -- 看输出里 FULLTEXT KEY 后面有没有 WITH PARSER ngram SHOW VARIABLES LIKE 'ngram_token_size'; -- 返回 2 说明最小匹配长度是 2 个字符

如果WITH PARSER没出现,删表重建,或者直接ALTER TABLE ai_nodes ADD FULLTEXT ft_kw_content(keyword, content) WITH PARSER ngram;。如果插件本身没装,就得先把ngram装上,绕不过去。另一条路是把召回从MATCH换成keyword LIKE :kw,能用,但中文短句的匹配率会掉得很明显,只建议临时救急。

5.2 现象:回答里带乱码,或者写进库的中文变成问号

utf8和utf8mb4混用。MySQL 里的utf8是三字节实现,存不下四字节字符(emoji、部分生僻字),PDO 默认连接字符集又常常是它,于是建库时看着是utf8mb4,实际写入时按三字节编码,超出部分直接被替换。

检查三处必须同时是utf8mb4:SHOW CREATE DATABASE ai_brain;、SHOW CREATE TABLE ai_nodes;、以及连接层的charset=utf8mb4。第三处最容易漏,它不在 SQL 里,在 DSN 字符串里。改完之后已有的坏数据不会自愈,得把那几行删掉重写。

5.3 现象:数据量到几万行后,每次提问要等两三秒

多半是走了全表扫描。看你有没有在MysqlMemory里图省事把MATCH换成LIKE '%词%'——前导通配符让任何索引都失效,行数一上来就是灾难。另一种情况是ORDER BY raw_score DESC, weight DESC里raw_score是计算列,优化器没法走索引,只能对候选集排序。

先用EXPLAIN确认:

EXPLAIN SELECT id, keyword, content, weight, MATCH(keyword, content) AGAINST ('+你好*' IN BOOLEAN MODE) AS raw_score FROM ai_nodes WHERE MATCH(keyword, content) AGAINST ('+你好*' IN BOOLEAN MODE) ORDER BY raw_score DESC, weight DESC LIMIT 5;

type列应该是fulltext。如果是ALL,索引没生效。退化方案是先用idx_keyword做一次前缀筛选把候选集压到几百行,再在 PHP 里算分排序,行数少的时候这个组合反而更快。

5.4 现象:不同用户的对话串在一起,A 问的问题 B 收到了答案

session_id生成有问题。这套代码里session_id是CHAR(32),正常应该用bin2hex(random_bytes(16))。如果直接用了session_id()或者时间戳取模,多人同时在线必串。另外ai_conversations上的UNIQUE KEY uk_session意味着同一个session_id只能插一次,第二次要INSERT ... ON DUPLICATE KEY UPDATE,否则并发下会抛唯一键冲突。

5.5 现象:跑了一周之后,所有问题都回答同一句话

权重失控。reinforce()只加不减,热门节点越滚越大,最终霸占所有排序。修法是给weight加上衰减:超过一定天数没被命中的节点按比例回落到基础值,或者在ORDER BY里引入时间因子。我在实际项目里的做法是每天凌晨跑一条UPDATE ai_nodes SET weight = GREATEST(1, FLOOR(weight * 0.9)) WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY);,简单粗暴但有效。另外ai_messages里存了每次命中的score,可以按周看分数分布,如果 90 分位的分数在持续下滑,就是权重该洗了。

6. 二次开发:把记忆表改造成能扛住真实流量的结构

这套代码作为学习材料是够的,直接上线会有点吃力,主要在召回精度和写入并发两块。我的改造顺序一般是这样:先不动架构,只加一层缓存;再动召回;最后才考虑换存储。

第一刀加缓存。ai_nodes的读远多于写,热门关键词的召回结果完全可以缓 60 秒。用文件缓存就够,别一上来就上 Redis:

// 在 MysqlMemory::recall 外面包一层,key 用 token 排序后拼接,保证同样的词命中同样的缓存 $cacheKey = 'recall:' . md5(implode('|', $tokens) . ':' . $limit); $file = sys_get_temp_dir() . '/ai_' . $cacheKey . '.json'; if (is_file($file) && time() - filemtime($file) < 60) { return json_decode((string) file_get_contents($file), true) ?: []; } // ... 原查询逻辑 file_put_contents($file, json_encode($rows), LOCK_EX);

LOCK_EX不能省,并发写同一个 key 时会读到半截 JSON,json_decode返回null,然后你会看到召回莫名其妙变成空数组。

第二刀动召回。词面匹配的天花板很低,最简单的提升是给ai_nodes加一列synonyms,存一组用逗号分隔的同义词,召回时把 token 扩展成同义词集合再拼MATCH。这一列的成本很小,但能救回大量改述。

改造方向改动点收益风险
同义词扩展ai_nodes.synonyms+ Tokenizer 扩展召回率明显上升同义词表膨胀后误召增加
权重衰减定时 SQL 或ORDER BY加时间因子避免答案收敛衰减太快会让新知识抢戏
会话上下文召回时带上最近 3 条ai_messages多轮问答不再失忆上下文窗口变大,SQL 变长
缓存层召回结果短 TTL 缓存QPS 提升一个量级新学的内容延迟可见

第三刀才是考虑换存储。真要做语义召回,MemoryInterface那五个方法就是你的迁移面,把MysqlMemory换成HybridMemory(MySQL 存全量 + 向量库存 embedding),Brain一行都不用改。这也是我一开始强调别在Responder里直接查库的原因。

验证改造是否有效,别凭感觉。我固定会跑的一组检查是:准备 30 条标注好标准答案的问句,跑一遍脚本统计命中率和平均分数,改造前后对比;再看ai_messages里score IS NULL的比例,这个比例反映的是召回彻底失败的量,比命中率更敏感。这套离线评估脚本不到一百行,但每次改召回参数我都会先跑它,比在页面上手动试快得多。

最后说个习惯。这套项目我前后改了三版,前两版都是改完直接上,结果每次都是「本地好好的,线上答非所问」。后来我给自己定了一条死规矩:任何改动召回或权重的提交,必须先在 CLI 里用那 30 条标注问句跑一遍,把命中率和分数分布打出来贴在提交说明里,数字不达标就不合。从那以后这类玄学问题基本绝迹了。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 5:43:15

WIN7调试iOS网页:Safari Web Inspector实战指南

简介&#xff1a;本资源是一款面向Windows平台&#xff08;含Win7&#xff09;用户的iOS/iPad模拟环境工具包&#xff0c;主要服务于移动应用开发者、测试人员及苹果生态初学者&#xff0c;解决在非苹果设备上体验iOS系统界面、调试基础应用或开展兼容性预研的需求。压缩包为RA…

作者头像 李华
网站建设 2026/10/24 5:42:02

千帆同步 CSDN 平台对接联调验证文章

千帆同步 CSDN 平台对接说明本文是千帆同步项目 CSDN 平台对接的联调验证文章&#xff0c;用于验证文章发布全链路&#xff1a;标题、正文 HTML、正文图片上传、文章标签、封面与发表接口。正文由富文本 HTML 注入 CKEditor正文图片上传 CSDN 图床后替换为 CDN URL标签、封面、…

作者头像 李华
网站建设 2026/10/24 5:41:53

基于C++ TauriCPP做了个能双击就跑的打地鼠

起因挺无聊的&#xff1a;某天想写个小游戏给家里小孩玩&#xff0c;又不想让他电脑上装一坨环境。Electron 打包出来太重&#xff0c;纯 Win32 画 UI 又嫌累&#xff0c;思来想去&#xff0c;用 WebView2 当渲染层、C 当逻辑层&#xff0c;TauriCPP最合适。 怎么把前端塞进 ex…

作者头像 李华
网站建设 2026/10/24 5:41:26

大学美育真题复盘:审美理论核心考点与易错点全解析

简介&#xff1a;这份资源是西北工业大学2021年春季学期大学美育课程考试真题&#xff0c;面向正在修读或备考大学美育的高校学生&#xff0c;以及需要梳理美学理论要点的自学者。题目覆盖判断题、多选题与案例分析&#xff0c;涉及审美主体、自然美、形式美、优美与崇高、审美…

作者头像 李华
网站建设 2026/10/24 5:41:23

MySQL进阶实战:核心原理与性能优化指南

1. MySQL 进阶实战&#xff1a;从安装配置到核心原理深度解析从事数据库开发十年&#xff0c;我见过太多开发者停留在基础的CRUD操作层面。实际上&#xff0c;MySQL的进阶能力往往决定着系统性能的上限。今天我们就从实战角度&#xff0c;拆解MySQL那些真正影响生产环境的关键技…

作者头像 李华
网站建设 2026/10/24 5:38:34

商标侵权怎么赔、找谁处理?法律规定都在这

遇到商标侵权可对照参考&#xff0c;以下条文均为法律原文。专用权与侵权认定第五十一条 注册商标的专用权&#xff0c;以核准注册的商标和核定使用的商品为限。第五十二条 有下列行为之一的&#xff0c;均属侵犯注册商标专用权。 (一)未经商标注册人的许可&#xff0c;在同一种…

作者头像 李华