资讯详情 MySQL当记忆、PHP当大脑:构建可查询的多轮对话AI架构
📅 2026/10/11 4:51:07
简介这份开源项目资源面向对人工智能与Web开发交叉领域感兴趣的PHP开发者及学习者尝试用PHP与MySQL搭建一个可运行、可扩展的AI系统原型。项目以面向对象PHP承担逻辑推理与交互充当系统“大脑”MySQL则作为“内存”负责训练数据、模型参数与决策历史的存储和查询为AI的学习与决策提供数据支撑。压缩包为zip格式整体约34KB文件总数暂未提供明细从描述可知核心包含入口脚本、模型类、数据库操作类、CSV训练数据、配置与日志等模块结构清晰便于二次开发。目前已有112人学习关注适合希望理解如何用现有Web技术栈构建AI解决方案的读者参考可借此掌握OOP设计、数据库交互与数据预处理的基本思路并参与开源协作完善系统。1. 用 MySQL 当记忆、PHP 当大脑这套 AI 架构到底在解决什么问题第一次看到「用 MySQL 当内存、OOP PHP 当大脑」这个说法我脑子里蹦出来的不是「这也能叫 AI」而是「终于有人把聊天机器人的状态管理讲人话了」。大部分 PHP 开发者做对话系统时第一反应是把上下文塞进 session 或者 Redis聊三句就丢用户回头问「我刚才说的那个订单呢」系统一脸茫然。这个项目的思路反过来把 MySQL 当成机器人的长期记忆层把 PHP 的类与对象当成推理和调度的骨架让「记住什么、忘掉什么、怎么取出来」变成可查询、可审计、可回滚的数据库操作。它适合谁适合手上只有 LNMP 环境、不想为了一个客服机器人去学 Python 向量库、又确实需要「多轮对话不丢上下文」的 PHP 工程师。核心价值不在模型多强而在于把 AI 的「记忆」从黑匣子变成一张你能SELECT的表。下面我从表结构设计一路讲到召回排序和踩坑全部是可复现的代码和参数。2. 记忆层设计MySQL 表结构怎么撑住多轮对话2.1 为什么不用 session 和 Redis 存上下文session 的问题是生命周期绑死在浏览器用户换个设备、关个页面记忆就断了。Redis 快但它是缓存语义默认没有持久化保证重启丢数据是常态而且你想按「时间衰减 关键词 用户维度」做复杂召回时Redis 的查询能力会让你写一堆 Lua 脚本。MySQL 的优势在于你可以用一条 SQL 同时完成过滤、排序、分页还能加索引优化最重要的是——数据可追溯。当机器人答错时你能查出来它当时到底「记得」什么这是调试对话系统最关键的一环。常见做法是分两张表一张存原始对话消息一张存提炼后的记忆摘要。原始消息保证不丢信息摘要表控制注入 prompt 的长度。我一般会再加一张用户画像表存偏好和事实性信息比如「用户叫 A 同学」「偏好简洁回答」。2.2 三张核心表的建表语句与字段含义-- 原始对话消息表每条消息一行保证完整可追溯 CREATE TABLE ai_messages ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(64) NOT NULL COMMENT 用户唯一标识, session_id VARCHAR(64) NOT NULL COMMENT 会话标识用于分组, role ENUM(user,assistant,system) NOT NULL, content TEXT NOT NULL COMMENT 消息正文, tokens INT UNSIGNED DEFAULT 0 COMMENT 预估token数用于裁剪, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_session_time (user_id, session_id, created_at), KEY idx_user_time (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 记忆摘要表把长对话压缩成短句控制注入长度 CREATE TABLE ai_memories ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(64) NOT NULL, summary VARCHAR(512) NOT NULL COMMENT 压缩后的记忆点, keywords VARCHAR(255) DEFAULT COMMENT 逗号分隔关键词用于召回, weight TINYINT UNSIGNED DEFAULT 5 COMMENT 重要度1-10影响排序, hit_count INT UNSIGNED DEFAULT 0 COMMENT 被召回次数用于热度加权, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_weight (user_id, weight DESC), FULLTEXT KEY ft_keywords (keywords, summary) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 用户画像表存稳定事实不随对话变化 CREATE TABLE ai_profiles ( user_id VARCHAR(64) PRIMARY KEY, nickname VARCHAR(64) DEFAULT , facts JSON COMMENT 结构化事实如{city:某市,lang:zh}, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段设计上有几个点值得说清楚。tokens字段不是装饰它是你做上下文裁剪的依据——当你要把最近 N 条消息拼进 prompt 时先按 token 累加超预算就往前砍。weight和hit_count是排序的两个维度weight 是人工或规则给的重要度hit_count 是系统自动累积的热度两者结合能避免「重要但很久没提」的记忆被彻底淹没。keywords上建 FULLTEXT 索引是为了在 MySQL 层面做一次粗召回不必把所有记忆都拉到 PHP 里过滤。注意utf8mb4 是必须的中文和 emoji 都靠它。FULLTEXT 在 InnoDB 上从 MySQL 5.6 起可用但中文分词默认按空格切效果有限所以 keywords 字段建议在写入时就由 PHP 侧分好词、用逗号连接。2.3 写入路径一条消息进来后先落库再处理?php class MessageRepository { private PDO $pdo; public function __construct(PDO $pdo) { $this-pdo $pdo; } // 写入一条消息返回自增ID public function save(string $userId, string $sessionId, string $role, string $content): int { $sql INSERT INTO ai_messages (user_id, session_id, role, content, tokens) VALUES (:uid, :sid, :role, :content, :tokens); $stmt $this-pdo-prepare($sql); $stmt-execute([ :uid $userId, :sid $sessionId, :role $role, :content $content, // 粗略估算中文约1字1token英文约4字符1token :tokens (int) ceil(mb_strlen($content) * 0.8), ]); return (int) $this-pdo-lastInsertId(); } // 取最近N条消息按时间正序返回供拼prompt public function recent(string $userId, string $sessionId, int $limit 20): array { $sql SELECT role, content FROM ai_messages WHERE user_id :uid AND session_id :sid ORDER BY id DESC LIMIT :limit; $stmt $this-pdo-prepare($sql); $stmt-bindValue(:uid, $userId); $stmt-bindValue(:sid, $sessionId); $stmt-bindValue(:limit, $limit, PDO::PARAM_INT); $stmt-execute(); // 倒序取出后翻转保证对话顺序正确 return array_reverse($stmt-fetchAll(PDO::FETCH_ASSOC)); } }这段代码的关键在recent()的排序技巧先ORDER BY id DESC取最新 N 条再在 PHP 里array_reverse。为什么不直接ASC因为你要的是「最近 N 条」用 DESC LIMIT 能走索引而 ASC LIMIT 取到的是最旧的 N 条语义就错了。tokens的估算系数 0.8 是经验值中文场景下偏保守宁可多算一点避免拼出来的 prompt 超模型上限。3. 大脑层实现OOP PHP 怎么组织推理与召回3.1 用接口隔离「记忆」和「模型」方便替换OOP 在这里不是炫技是为了让「换模型」和「换存储」互不影响。我一般定义两个接口MemoryInterface负责记忆的读写召回BrainInterface负责把上下文变成回复。这样你从 MySQL 换到别的存储、从某家 API 换到本地模型都只改一个实现类。?php interface MemoryInterface { public function recall(string $userId, string $query, int $topK 5): array; public function remember(string $userId, string $summary, array $keywords, int $weight 5): void; } interface BrainInterface { // 传入系统提示、历史消息、召回记忆返回模型回复文本 public function think(string $systemPrompt, array $history, array $memories): string; }接口的价值在于测试。你可以写一个FakeBrain返回固定字符串把整条链路跑通不用真的调模型花钱。这是我在实际项目里最常用的调试手段——先验证记忆读写对不对再接真模型。3.2 召回排序weight、hit_count 和时间衰减怎么加权召回不是简单ORDER BY updated_at DESC那样老的重要记忆永远出不来。我的做法是在 SQL 里算一个综合分再在 PHP 里做关键词精排。?php class MysqlMemory implements MemoryInterface { private PDO $pdo; public function __construct(PDO $pdo) { $this-pdo $pdo; } public function recall(string $userId, string $query, int $topK 5): array { // 综合分 重要度*2 热度*0.5 时间新鲜度 // 时间新鲜度7天内满分10之后每过7天减2最低0 $sql SELECT id, summary, keywords, weight, hit_count, (weight * 2 LEAST(hit_count, 20) * 0.5 GREATEST(10 - FLOOR(DATEDIFF(NOW(), updated_at) / 7) * 2, 0) ) AS score FROM ai_memories WHERE user_id :uid ORDER BY score DESC LIMIT :limit; $stmt $this-pdo-prepare($sql); $stmt-bindValue(:uid, $userId); $stmt-bindValue(:limit, $topK * 3, PDO::PARAM_INT); // 多取一些供精排 $stmt-execute(); $rows $stmt-fetchAll(PDO::FETCH_ASSOC); // 关键词命中加权query分词后与keywords求交集 $terms $this-tokenize($query); foreach ($rows as $row) { $kws array_filter(explode(,, $row[keywords])); $hit count(array_intersect($terms, $kws)); $row[score] $hit * 3; // 命中一个关键词加3分 } unset($row); usort($rows, fn($a, $b) $b[score] $a[score]); $top array_slice($rows, 0, $topK); // 被召回的记忆热度1用于后续排序 if ($top) { $ids array_column($top, id); $in implode(,, array_fill(0, count($ids), ?)); $this-pdo-prepare(UPDATE ai_memories SET hit_count hit_count 1 WHERE id IN ($in)) -execute($ids); } return $top; } private function tokenize(string $text): array { // 简易分词按非中文非字母数字切分实际项目可接分词库 preg_match_all(/[\x{4e00}-\x{9fa5}]|[a-zA-Z0-9]/u, $text, $m); return array_unique($m[0]); } public function remember(string $userId, string $summary, array $keywords, int $weight 5): void { $sql INSERT INTO ai_memories (user_id, summary, keywords, weight) VALUES (:uid, :summary, :kw, :w); $this-pdo-prepare($sql)-execute([ :uid $userId, :summary mb_substr($summary, 0, 500), :kw implode(,, array_slice($keywords, 0, 10)), :w max(1, min(10, $weight)), ]); } }排序公式里的系数不是拍脑袋weight * 2让重要度成为主导hit_count * 0.5且用LEAST封顶 20是防止某条记忆被反复召回后热度无限膨胀、挤掉其他内容。时间衰减用DATEDIFF / 7按周递减GREATEST(..., 0)保证不会变成负数。关键词命中加 3 分是因为精确匹配的语义相关性通常比时间新鲜度更值得信任。这些参数你都可以调但建议一次只调一个观察召回结果的变化。3.3 拼装 prompt把历史、记忆、画像合成一个上下文?php class ContextBuilder { public function __construct( private MessageRepository $messages, private MemoryInterface $memory, private int $tokenBudget 2000 ) {} public function build(string $userId, string $sessionId, string $userInput): array { $history $this-messages-recent($userId, $sessionId, 20); $memories $this-memory-recall($userId, $userInput, 5); // 记忆拼成一段背景放在system里 $memoryText ; foreach ($memories as $m) { $memoryText . - . $m[summary] . \n; } $system 你是一个有长期记忆的助手。以下是关于该用户的已知信息\n . $memoryText; // 从最近往远裁剪历史直到token预算够用 $picked []; $used (int) ceil(mb_strlen($system) * 0.8); foreach (array_reverse($history) as $msg) { $cost (int) ceil(mb_strlen($msg[content]) * 0.8); if ($used $cost $this-tokenBudget) break; $picked[] $msg; $used $cost; } return [$system, array_reverse($picked)]; } }裁剪逻辑是「从最近往远加加不下就停」这样保证最近的对话一定在上下文里牺牲的是更早的历史——但那些历史已经被摘要表覆盖了这正是双表设计的价值。tokenBudget默认 2000 是保守值你要根据实际模型上限调整留出回复所需的空间。4. 避坑与排查这套架构最容易翻车的五个地方4.1 中文 FULLTEXT 召回几乎无效现象用MATCH ... AGAINST查关键词明明记忆里有「退款流程」搜「退款」却查不到。原因是 InnoDB 默认分词按空格和标点切中文整句被当成一个词。解决不要依赖 FULLTEXT 做中文召回改用 PHP 侧分词后写进keywords字段用LIKE或FIND_IN_SET匹配或者干脆全量取出在 PHP 里算交集。数据量上万条后再考虑接专业分词。4.2 记忆表无限膨胀召回越来越慢现象跑了一个月ai_memories几十万行recall从 10ms 涨到 800ms。原因是每次对话都remember没有合并和淘汰。解决加定时任务把同一用户 weight 低于 3 且 30 天未命中的记忆归档到历史表对语义重复的摘要做合并比如两条都讲「用户在某市」保留 weight 高的那条。索引上确保(user_id, weight DESC)存在让排序走索引。4.3 hit_count 更新导致行锁竞争现象高并发下UPDATE ai_memories SET hit_count hit_count 1出现锁等待接口超时。原因是同一用户的记忆被频繁召回行锁排队。解决把 hit_count 的更新改成异步——先写进一张memory_hits流水表定时批量汇总回主表或者用 Redis 计数器缓冲每隔 N 秒刷一次。对话主链路里不要做写操作。4.4 token 估算偏差导致 prompt 超限现象偶尔报「context length exceeded」但按估算明明没超。原因是mb_strlen * 0.8对代码块、JSON、英文长词严重低估。解决把系数提到 1.0 甚至 1.2 做保守估算或者接入真正的 tokenizer 库。更稳的做法是在ContextBuilder里加一道保险拼完后如果总长度超过硬上限的 90%强制再砍掉最旧的两条消息。4.5 摘要质量差记忆变成噪音现象召回了一堆记忆但模型回复反而更差因为摘要里全是「用户说了你好」这种废话。原因是remember的触发规则太粗把寒暄也存了。解决在写入前加过滤——只存包含事实、偏好、承诺、数字的内容用规则或小模型判断给摘要设最小长度比如 10 字和最大长度500 字weight 默认给 5但寒暄类给 1让排序自然淘汰。5. 进阶技巧让记忆自己学会「该记什么」跑到一定阶段你会发现手动规则决定「记什么」很快就不够用。我的做法是加一个「记忆提炼器」在每轮对话结束后异步跑一次判断这轮有没有值得长期保留的信息。核心思路是把最近几轮对话喂给模型让它输出结构化的记忆条目再写进ai_memories。?php class MemoryExtractor { public function __construct(private BrainInterface $brain, private MemoryInterface $memory) {} // 在对话结束后调用异步执行 public function extract(string $userId, array $recentTurns): void { $prompt 从以下对话中提取值得长期记住的事实、偏好或承诺。 . 每条一行格式摘要|关键词1,关键词2|重要度1-10。 . 没有则输出NONE。\n\n; foreach ($recentTurns as $t) { $prompt . $t[role] . : . $t[content] . \n; } $result $this-brain-think(你是记忆提炼器。, [], [[summary $prompt]]); if (trim($result) NONE) return; foreach (explode(\n, trim($result)) as $line) { $parts explode(|, $line); if (count($parts) 3) continue; [$summary, $kw, $weight] $parts; // 去重同用户下摘要完全相同的跳过 if ($this-isDuplicate($userId, trim($summary))) continue; $this-memory-remember( $userId, trim($summary), explode(,, trim($kw)), (int) trim($weight) ); } } private function isDuplicate(string $userId, string $summary): bool { // 简化实现实际可用相似度或哈希前缀 return false; } }这个提炼器的价值在于把「记什么」的决策从硬编码规则变成模型判断但要注意两点一是它必须异步不能阻塞用户回复二是要有去重和限流否则模型可能每轮都输出相似记忆把表撑爆。我一般会限制单用户每天新增记忆不超过 50 条超了就只保留 weight 最高的。验证这套架构有没有跑通我习惯看三个指标召回命中率召回的记忆里有多少被模型实际引用、记忆增长率每天新增条数是否稳定、平均召回耗时P95 是否在 50ms 内。这三个数稳住说明记忆层和大脑层的配合是健康的。我自己踩过最深的坑是早期没做异步把 hit_count 更新放在主链路结果一次促销活动流量上来整个对话接口雪崩——从那以后凡是写操作一律挪出用户等待路径。希望帮到你。本文还有配套的精品资源点击获取