这两年大家讲AI应用上来就是Python、FastAPI、向量数据库好像离开这套就寸步难行。但我接手过一个很现实的需求公司有套跑了多年的PHP业务系统数据都在MySQL里现在想给这套老系统加一个“能查文档、能按步骤办事”的AI助手。说白了就是要在LAMP环境里把RAG、Agent和LLM串起来。我一开始也头大真跑通之后发现这条路不但走得通而且比想象中稳。这篇就完整复盘一下我在这个项目里的方案选型、落地细节和踩坑记录给同样被困在存量系统里的同学一个参考。1. 整体方案设计与思路拆解1.1 这个需求背后真正要解决的事先说清楚这个项目挂的标题是“LAMP环境部署 RAG Agent LLM”但你要是理解成“在Linux上装三个开源软件”那后面一定会跑偏。真实需求往往比技术标签复杂得多。我这边的情况是业务方手里的核心资产是几千份产品文档、操作手册和历史工单还有一个跑了好几年的用户管理体系用户角色、权限、操作记录全在里面。他们想要的AI助手不是简单做一个聊天框而是要让用户用自然语言提问系统能自动去这些文档资料里找答案并且如果用户想办某个具体事项比如查某个订单状态、发起某个审批流程AI得能调用后台已有的能力去做。这就注定了方案必须同时包含三个东西LLM负责理解和生成自然语言RAG负责把私域知识喂给模型减少幻觉Agent负责把“问”变成“做”。而且这套东西不是从零起高楼必须接到LAMP这套旧地基上。Apache本来就在扛线上流量MySQL里存着核心业务数据PHP代码是团队最熟悉的东西你不可能为了上个AI就把这些推翻重来。所以核心矛盾不是“用什么模型”而是“怎么让旧系统和新AI能力和平共处”这也是整个方案设计的出发点。1.2 RAG、Agent、LLM在架构里的分工很多第一次接触这套组合的人会把三个概念搅在一起觉得是同一个东西。实际上在架构里它们各管一段职责边界很清晰。LLM是“大脑”负责读懂用户的话也负责生成最终回答但它的问题在于训练时没见过你们公司的内部资料容易一本正经胡说八道。RAG是“外挂记忆”先把文档切块、向量化存进数据库用户提问时先做一次检索把最相关的几段资料捞出来拼进提示词一起喂给LLM相当于开卷考试模型拿着资料回答准确性立刻上了一个台阶。Agent是“调度中枢”负责拆解任务、决定什么时候去查资料、什么时候调用现成接口、什么时候反问用户补充信息。这三个东西协作起来的典型链路是用户提问进入Apache → PHP接收请求 → 启动Agent流程 → Agent判断需要依据资料触发RAG检索 → 检索结果连同问题交给LLM → LLM返回回答 → Agent把回答包装成最终结果返回前端。如果用户提的是“帮我查一下某个流程走到哪了”Agent就不会去检索文档而是直接调用PHP里已有的业务接口拿数据再让LLM帮忙组织成一句话。所以Agent和RAG不是二选一而是互相配合Agent决定了“要不要查、查什么”RAG解决“查出来怎么用”。1.3 为什么选择“LAMP AI旁路”而不是另起炉灶我第一次和团队成员讨论时有人提过干脆用Python重写一个AI服务再单独部署个向量库和老系统完全隔离开。这个思路技术上没问题但落地时要面对三個很现实的阻力一是成本新服务得申请新机器、维护新代码库、培养新运维能力二是数据打通难题业务数据在MySQL里你若把AI服务放外面就得做一套数据同步机制权限模型也要重写一遍三是团队技能断层团队里的PHP工程师占了大多数让他们去维护一套Python为主的AI平台遇到线上问题人都凑不齐。所以我最后定下来的原则是“LAMP负责一切外部入口和业务逻辑AI能力以旁路方式挂载到这套环境里”。也就是说Apache还是那个ApachePHP还是那个PHPMySQL还是核心存储额外的模型推理和向量检索通过PHP调本地Python脚本或者独立服务来实现。这个选择的好处非常直接线上架构不用动权限、会话、日志都留在原系统里AI只是被当成一种更高级的“处理函数”接入进去。等这套跑稳了再考虑抽独立服务都不迟。2. 核心组件选型与解析2.1 LLM接入API起步本地模型兜底LLM是整个系统里最敏感的组件因为它的能力直接决定回答质量。我当时的选型思路是先不要纠结开源还是闭源先看你们到底能不能接受数据出内网。如果对数据合规要求高比如内部运维手册、客户敏感信息那就得优先考虑本地推理比如用vLLM或者Ollama跑一个开源模型如果只是处理普通FAQ和产品介绍直接调大模型API服务反而效率更高不用管显卡温度也不用担心并发打爆。我这儿最终是两手准备线上默认走服务商API因为当时团队要求每周上线API接入最快同时在一台闲置机器上部署了一个7B量级的本地模型做备胎专门处理API超时或者内网隔离时段的请求。为了不让PHP代码绑死在某一家服务上我封装了一个统一的LLM调用接口业务侧只传“系统提示词、用户消息、上下文片段”三个参数底层是走的API还是本地推理由配置文件决定。这个抽象层看着不起眼但后面帮我省了大事有次接的API服务临时调整定价我改一行配置就切到备胎了前端业务完全无感。2.2 RAG链路解析、分块、向量化、检索不能省任何一步RAG听起来高深拆开就是四个环节文档解析、内容分块、向量化、相似度检索。这四个环节点点都藏坑。文档解析我吃过亏一开始图方便直接用现成库PDF转文本结果产线手册里一堆表格变成了乱码索引进去等于埋了定时炸弹。后来老实上了多格式解析流程PDF单独走解析组件保留标题结构Word文档转成文本后清洗掉多余换行表格内容转成逻辑文本行再入库。分块策略是RAG效果好坏的关键。块切太大比如整章塞进去检索时语义虽近但混入了大量无关内容大模型的注意力被稀释块切太小比如按一句话切又容易丢失上下文检索回来像看碎片。我这边试下来中文技术文档800到1200字左右一块比较稳重叠区设个100字保证跨块的内容不会断掉。向量化模型我用的是开源中文向量模型不需要自己训练下载下来直接嵌入到Python脚本里用就行。检索的时候也不是只取最相似的一段而是取前5段再按相关度排序拼进提示词有效果多了。2.3 Agent层用PHP编排的最小闭环Agent这个词这两年有点被用烂了其实落到这套系统里它干的事就是“根据用户意图调用不同能力”。我实现的第一版Agent很简单没有复杂的记忆和反思链就是一个三步闭环“理解指令 → 执行动作 → 组织回答”。理解指令这一步PHP把用户问题和对话上下文打包发给LLM让LLM输出一个结构化意图比如“意图文档问答”“意图订单查询”“意图闲聊”执行动作这一步PHP根据意图走不同分支文档问答就调RAG检索订单查询就直接查业务表组织回答这一步再把结果二次交给LLM润色。这套简陋的Agent已经能覆盖80%的线上问题。我没有一上来就给PHP装一堆Agent框架因为老系统能力就是现成的PHP函数你搞个重框架反而要跨语言维护。最轻量的做法就是用一张意图路由表把“用户说的话”映射到“PHP里已有的函数”剩下的是LLM把非结构化输入转成结构化参数。等你积累多了再演进成多轮规划都不晚。我见过不少团队第一步就上豪华Agent框架结果链路一旦出错排障时间长得怀疑人生。3. 实操过程与核心代码实现3.1 环境准备与版本选择这套方案对底软的要求不高不用配GPU也能先把流程跑通。我的环境是这样的组件版本/配置说明Linux任意主流发行版建议8G内存以上本地要跑向量模型会更吃紧Apache2.4启用rewrite和proxy模块用来转发请求MySQL5.7建议8.0用JSON字段和生成列更方便PHP7.4需要启用pdo_mysql扩展curl扩展用于HTTP调用Python3.9独立虚拟环境装向量模型依赖和检索脚本LLM先接API后接本地模型PHP侧只要求能按接口调用实际操作时不要一上来就在正式环境折腾我习惯先用一台开发机把全流程跑通包括PHP调用Python这一步确认IO和权限都没问题再上生产线。PHP调用Python最朴素的方式就是exec或shell_exec但生产环境千万不要用没做过参数白名单的裸调我后面在排错部分会细说。3.2 数据库设计与SQL脚本MySQL在这里既要存业务数据又要当向量库用。虽说专业向量库性能更强但在数据量还没上百万的阶段用MySQL存向量完全能扛住还能省掉一套额外组件的运维负担。我建了三张表核心结构如下-- 文档表 CREATE TABLE ai_document ( id INT PRIMARY KEY AUTO_INCREMENT, doc_name VARCHAR(255) NOT NULL, doc_type VARCHAR(50) NOT NULL, upload_path VARCHAR(500) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 0 COMMENT 0待处理 1已入库 2失败 ); -- 文档分块表存储切好的块和向量 CREATE TABLE ai_chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id INT NOT NULL, chunk_index INT NOT NULL, chunk_text TEXT NOT NULL, chunk_tokens INT NOT NULL DEFAULT 0, embedding LONGBLOB NOT NULL COMMENT 向量序列化后的二进制, KEY idx_doc_id (doc_id), KEY idx_chunk_idx (chunk_index) ); -- 意图路由日志表 CREATE TABLE ai_agent_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, question TEXT NOT NULL, intent VARCHAR(50) NOT NULL, answer TEXT, latency_ms INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );向量存LONGBLOB是因为MySQL原生没有向量类型我们可以用Python把向量序列化成字节流存进去检索时在SQL里做不了完整的余弦计算就先把所有向量取出来在Python里算。等数据量真大了再迁移到MySQL的向量插件或者外挂一个专用向量库都来得及。我自己就是先把程序跑起来索引、分页、性能优化都是后置的不要一开始就陷入和大厂架构对比的焦虑里。3.3 PHP主流程代码PHP是这套系统的门面它负责接收前后端请求、调度Agent流程、调用LLM和Python检索。我写了一个简化但完整的主控制器麻雀虽小五脏俱全?php class AiAssistantController { private $llmProvider; public function __construct() { $config require llm_config.php; $this-llmProvider new LlmProvider($config); } public function askAction() { $userId $_POST[user_id] ?? 0; $question trim($_POST[question] ?? ); if ($question ) { $this-jsonResponse([error question is empty], 400); } // 第一步让LLM判断用户意图 $intent $this-detectIntent($question); // 第二步按意图走不同分支 $answer ; $latencyStart microtime(true); switch ($intent) { case doc_qa: $answer $this-handleDocQa($question); break; case order_query: $answer $this-handleOrderQuery($userId, $question); break; default: $answer $this-llmProvider-chat([ system 你是一个友好的AI助手请简洁回答。, user $question ]); } $this-logAgent($userId, $question, $intent, $answer, (int)((microtime(true) - $latencyStart) * 1000)); $this-jsonResponse([answer $answer, intent $intent]); } private function detectIntent(string $question): string { $result $this-llmProvider-chat([ system 根据用户问题判断意图只输出一个词doc_qa表示询问文档内容order_query表示查询业务数据other表示其他。, user $question ]); return trim($result); } private function handleDocQa(string $question): string { // 调用Python脚本检索相关文档块参数用 escapeshellarg 防止注入 $cmd python3 /var/www/ai_engine/retrieval.py . escapeshellarg($question) . 21; $output shell_exec($cmd); $retrieved json_decode($output, true); if (!$retrieved || empty($retrieved[chunks])) { return 在知识库中没有找到相关问题相关的资料。; } // 把检索到的内容拼接给LLM $context implode(\n---\n, array_column($retrieved[chunks], text)); return $this-llmProvider-chat([ system 你是知识库助手。请只依据提供的资料回答问题不要编造。资料如下 . $context, user $question ]); } private function handleOrderQuery(int $userId, string $question): string { // 这里调用已有的业务服务复用老代码 $orderService new OrderService(); $orders $orderService-getUserRecentOrders($userId); $summary $this-llmProvider-chat([ system 根据用户订单信息用一句话回答用户关于订单的提问。, user $question . 订单信息 . json_encode($orders, JSON_UNESCAPED_UNICODE) ]); return $summary; } }这段代码里有个容易被忽略的点检索结果的Prompt拼装。我一开始把全部检索结果一股脑塞进去导致LLM回答跑偏后来加了“只依据提供的资料回答”这句限制幻觉立刻少了很多。另外LLM的判断结果未必永远稳定我加了一个简单的兜底如果意图识别结果不在白名单里就走默认聊天分支不能因为模型抽风导致整个接口直接报错。3.4 Python检索脚本Python在系统里的定位就是干“向量化 检索”这种脏活累活。脚本接收用户问题把问题向量化然后从MySQL里把所有已入库的文档块向量拉出来算余弦相似度返回最相关的几段文本。数据量大的时候全表拉取不现实这时候可以做简单优化先按文档类别过滤或者对向量做PCA降维减少计算量。我这边初期数据量不大全量算也就几百毫秒足够用了。#!/usr/bin/env python3 import sys import json import numpy as np import pymysql from sentence_transformers import SentenceTransformer # 模型全局加载避免每次请求重复加载 model SentenceTransformer(BAAI/bge-small-zh-v1.5) def get_embedding(text): vec model.encode(text, normalize_embeddingsTrue) return vec.astype(float32) def retrieve(question, top_k5): conn pymysql.connect( host127.0.0.1, userai_user, passwordyour_password, databaseyour_db, charsetutf8mb4 ) try: with conn.cursor() as cur: cur.execute(SELECT id, chunk_text, embedding FROM ai_chunk) rows cur.fetchall() q_vec get_embedding(question) results [] for row in rows: chunk_id, text, emb_bytes row emb np.frombuffer(emb_bytes, dtypefloat32) # 余弦相似度因为已经L2归一化点积就是余弦 score float(np.dot(q_vec, emb)) results.append((score, text)) results.sort(keylambda x: x[0], reverseTrue) return [{text: text, score: score} for score, text in results[:top_k]] finally: conn.close() if __name__ __main__: question sys.argv[1] data retrieve(question) print(json.dumps({chunks: data}, ensure_asciiFalse))这个脚本背后有个关于向量归一化的细节值得说模型输出向量后我们做了L2归一化归一化之后再算点积就是余弦相似度。很多人会忘记这一步导致明明应该是“越相似越接近1”的结果算出来却是杂乱无章的数字。另外模型我选择在脚本里全局加载一次而不是每次请求都加载否则延迟会从几百毫秒直接飙到几秒。整个脚本用纯Python实现不依赖重型框架部署时只要pip装好依赖就行。3.5 队列机制与并发处理AI接口有一个很让人头疼的问题慢。LLM推理本身可能要几秒加上RAG检索整体响应奔着十秒去。而Apache/PHP的默认执行机制是同步阻塞用户请求一多PHP-FPM的进程很容易被占满。我这边做了一层异步化用户提交问题后PHP先把任务写进MySQL的任务表然后前端轮询结果。这样即使用户不走长连接也不会把PHP进程拖死。实现方式也不复杂任务表里加一个status字段标记“排队中/处理中/已完成/失败”PHP后台用crontab每分钟扫一次任务表把新任务丢给一个常驻Python消费进程去跑跑完回写答案。这个异步改造带来的收益非常明显线上高峰期再没有出现PHP进程被AI请求占满的情况。如果你不想上任务队列组件一张表加一个crontab就是最轻量的方案实测足够支撑中小团队。4. 常见问题与排查技巧实录4.1 向量检索慢得离谱第一个踩的坑就是检索脚本线上性能崩坏。开发环境只有几百条数据随便算上线后文档库涨到几万块每次查询把全库向量拉到内存算耗时从几百毫秒涨到几秒甚至十几秒用户直接抱怨“转圈圈”。这个问题光靠调代码是优化不彻底的本质是检索算法需要索引。我做了两件事第一给文档先做了粗分类检索时先按用户问题命中的分类过滤只算相关分类里的向量相似度第二如果某类文档还是特别多再上聚类索引先用KMeans把块聚成几十个簇检索时先定位最相关的几个簇再在簇内精确计算。这两步加起来检索耗时压回到了500毫秒以内。不要一上来就迁移到专用向量库先把业务分类和聚类用起来很多时候根本不用上重武器。4.2 PHP频繁调用Python导致的性能瓶颈另一个印象深刻的问题是shell_exec方式调用Python脚本的频率一高性能就崩。每来一个请求就fork一个Python进程光加载模型就要几秒比检索本身还慢。这里有个教训慢的来源不在计算而在进程启动和模型加载。后来我把常驻Python进程方案落地了用Python起一个HTTP服务或用消息队列接收PHP发来的任务模型只加载一次所有检索请求通过常驻进程处理。PHP这边改成用curl发请求不再shell_exec。这个改造非常关键切换之后单次检索的耗时从3秒降到300毫秒以内。如果你不想维护一个独立HTTP服务用系统自带的消息队列或者Redis列表也可以核心思路就是“PHP只负责投递和取结果Python常驻消费”。4.3 大模型上下文长度超限RAG坑最多的就是上下文超限。因为检索回来的文档块可能很长拼上系统提示词、历史对话分分钟超过模型的上下文窗口。尤其是用API服务时有严格Token限制超了直接报错。我一开始很崩溃明明检索质量不错结果模型拒绝回答。排查后我加了三个策略一是对检索回来的每个块做截断超过设定长度就按句号切割丢掉后半部分二是对历史对话做滑动窗口只保留最近两轮三是如果拼装后仍然超限就降低top_k数量。这三个策略按优先级嵌套实测极少再触发超限错误。另外特别提醒做字节截断时中文可能把字切坏所以不要按字符硬切按正则切到句号或问号这些完整标点处最稳。4.4 乱码与内容分块错乱中文环境下最容易出这种看起来低级其实影响很大的问题。我第一次导入生产手册时PDF转出来的文本里PDF换行符和正常标点混在一起分块后的文字明显是断的。更隐蔽的是MySQL连接字符集没设utf8mb4存进去的向量文本全是问号检索出来质量一塌糊涂。排查方法很朴素先把入库前的原始文本打印出来看再看库里实际存的值最后看检索到的是不是正确内容。一般问题出在三个位置解析器输出、PDO/连接串字符集、Python取数时的charset参数。逐层把编码统一到UTF-8问题就好了一大半。还有一个小技巧分块时可以按“标题/段落”标记切分而不是单纯按字数切这样能显著减少内容错乱。4.5 调试技巧与监控指标整套链路涉及三个子系统加一个数据库出了问题排起来确实费劲。我最常用的一套调试方法先只看“用户问题 → 意图识别结果”这段确认LLM没跑偏再看“检索结果的相关度分数”确认RAG捞回来的确实是相关内容最后才看“最终回答”确认模型有没有忠实于资料。每段都有日志任何一段不对都能快速定位。我建了三张日志相关的表记录这些中间结果同时也监控四个核心指标意图识别准确率、检索召回率看top5里是否包含标准答案、响应耗时、超时率。部署完不是结束我前两周每天都会随机挑几条用户真实问题手工核对效果发现哪一类问题经常答错就去优化分块规则或者补充知识库。上线之后这套系统才真正开始被磨得顺手。写在最后的小建议这套LAMP RAG Agent LLM的方案说实话并不是什么高精尖架构但它贵在贴合现实不动存量系统、不折腾团队技能、不引入过多新组件。我在实操中最深的体会是这类项目最大的瓶颈往往不是模型选得不够好而是把老系统和新AI能力缝在一起的那些胶水代码有没有考虑周全。把PHP的调度、Python的检索、MySQL的存储这三者的边界理清楚比反复换更强的模型重要得多。如果你也正在存量LAMP环境上摸索AI落地希望这篇踩坑实录能帮你少走一些弯路。
阅读完成 · 觉得有帮助?