
简介这份开源项目面向希望借助传统 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)按关键词召回候选节点BrainfindByKeyword(string $kw)精确查一个词用于去重Learnerstore(KnowledgeNode $node)插入一条新记忆Learnerreinforce(int $id, int $delta)给已有节点加权LearnerlogMessage(Message $msg)落会话流水只写不读Brain2.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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 消息流水只追加不更新用于事后复盘它到底怎么想的 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 词频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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个参数需要特别说明。WITH PARSER ngram是中文能用全文索引的前提MySQL 自带的默认分词器按空格切中文整段会被当成一个词匹配几乎永远为 0。ngram_token_size默认是 2意味着少于两个汉字的查询词基本检不出来这一点后面排查章节还会再提。weight用INT而不是浮点是因为它只做排序权重不需要精度整数在索引里比较更快。ai_messages.score保留四位小数是为了让你事后能按分数分布调整阈值——我一开始用TINYINT存百分制结果发现大量召回都挤在 30 分附近粒度根本不够看。至于为什么不用向量库这套代码的定位本来就不是语义理解。它的召回是词面匹配加权重排序好处是零依赖、能塞进任何一台装了 MySQL 的机器坏处是同义改述完全接不住。我在实际项目里通常是拿它做第一层粗筛语义召回再看需求往上加而不是一上来就摆一个向量服务然后发现运维成本比业务还高。2.3 大脑的三段式流水线归一化 → 召回 → 应答Brain类的reply()是整个项目的心脏五步流水线写在一个方法里短到我第一次看还以为是占位实现?php declare(strict_types1); 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_types1); 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; } }charsetutf8mb4写在 DSN 里不能只在建库时指定。这是最常见的组合坑库是utf8mb4连接是默认的utf8其实是三字节的utf8mb3生僻字和 emoji 一进来就变成?而且 PDO 不会报错只是默默写坏数据。ATTR_EMULATE_PREPARES false也要显式关掉否则 MySQL 侧看到的是拼接后的 SQLLIMIT :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_types1); 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_types1); 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_types1); 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_types1); 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实际写入时按三字节编码超出部分直接被替换。检查三处必须同时是utf8mb4SHOW CREATE DATABASE ai_brain;、SHOW CREATE TABLE ai_nodes;、以及连接层的charsetutf8mb4。第三处最容易漏它不在 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 时会读到半截 JSONjson_decode返回null然后你会看到召回莫名其妙变成空数组。第二刀动召回。词面匹配的天花板很低最简单的提升是给ai_nodes加一列synonyms存一组用逗号分隔的同义词召回时把 token 扩展成同义词集合再拼MATCH。这一列的成本很小但能救回大量改述。改造方向改动点收益风险同义词扩展ai_nodes.synonyms Tokenizer 扩展召回率明显上升同义词表膨胀后误召增加权重衰减定时 SQL 或ORDER BY加时间因子避免答案收敛衰减太快会让新知识抢戏会话上下文召回时带上最近 3 条ai_messages多轮问答不再失忆上下文窗口变大SQL 变长缓存层召回结果短 TTL 缓存QPS 提升一个量级新学的内容延迟可见第三刀才是考虑换存储。真要做语义召回MemoryInterface那五个方法就是你的迁移面把MysqlMemory换成HybridMemoryMySQL 存全量 向量库存 embeddingBrain一行都不用改。这也是我一开始强调别在Responder里直接查库的原因。验证改造是否有效别凭感觉。我固定会跑的一组检查是准备 30 条标注好标准答案的问句跑一遍脚本统计命中率和平均分数改造前后对比再看ai_messages里score IS NULL的比例这个比例反映的是召回彻底失败的量比命中率更敏感。这套离线评估脚本不到一百行但每次改召回参数我都会先跑它比在页面上手动试快得多。最后说个习惯。这套项目我前后改了三版前两版都是改完直接上结果每次都是「本地好好的线上答非所问」。后来我给自己定了一条死规矩任何改动召回或权重的提交必须先在 CLI 里用那 30 条标注问句跑一遍把命中率和分数分布打出来贴在提交说明里数字不达标就不合。从那以后这类玄学问题基本绝迹了。希望帮到你。本文还有配套的精品资源点击获取