ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零搭建AI工程能力:自底向上实现RAG与推理服务

从零搭建AI工程能力:自底向上实现RAG与推理服务 1. 从零搭建AI工程能力为什么我劝你别一上来就调包这两年AI应用开发的门槛肉眼可见地降低了一个刚入门的开发者花一个下午就能用现成的框架跑通一个对话机器人。但我在带团队和做技术评审的过程中发现一个很普遍的现象很多人能跑通Demo却说不清楚一次推理请求背后到底发生了什么模型加载慢在哪里、显存为什么爆、Token是怎么被切分的、向量检索的召回率为什么上不去全都是一笔糊涂账。ai-engineering-from-scratch这个方向说白了就是把这些被框架封装掉的环节一层一层拆开自己动手实现一遍。它不是一个具体的开源库而是一种学习路径和工程方法论——从最底层的张量运算、注意力机制到推理服务、RAG检索增强、Agent编排全部用最小可运行的代码自己搭一遍。这篇文章适合谁看如果你已经会用LangChain、LlamaIndex或者某个推理框架但总觉得自己像个“调包侠”遇到线上问题就抓瞎那这篇内容就是写给你的。如果你是完全零基础也没关系我会尽量用生活化的类比把原理讲清楚同时给出可以直接抄作业的代码和参数。整篇内容会围绕四个核心板块展开整体设计思路、核心细节与实操要点、完整实操流程、以及常见问题排查。每个板块我都会补充大量在真实项目中踩过的坑和总结出来的经验力求让你看完之后能自己动手复现一套最小可用的AI工程链路。我个人的观点很明确AI工程能力不是靠读文档读出来的也不是靠调API调出来的而是靠“自己造一遍轮子”造出来的。你不需要造得比生产级框架好但你必须知道轮子是怎么转的。下面我就按照这个思路把整个从零搭建的过程拆解给你看。2. 整体设计与思路拆解2.1 为什么选择“自底向上”而不是“自顶向下”大部分人的学习路径是自顶向下的先学框架API再学Prompt工程最后才去了解底层原理。这条路看起来快实际上后劲不足。我见过太多人卡在“为什么我的RAG效果不好”这个问题上然后开始盲目地换Embedding模型、换向量库、调TopK参数折腾一圈下来问题依旧。根本原因在于他不知道检索质量差是出在文本切分环节还是向量化环节也不知道召回率和精确率之间怎么权衡。自底向上的路径则完全不同。你先自己实现一个最简单的分词器就会明白为什么中文分词和英文分词策略差异这么大你自己手写一遍Scaled Dot-Product Attention就会理解为什么序列长度翻倍会导致显存平方级增长你自己搭一个最朴素的向量检索就会知道余弦相似度和欧氏距离在不同场景下该怎么选。这些认知一旦建立起来再去用框架你就能一眼看出框架在哪个环节做了什么取舍出了问题也能快速定位。所以整个项目的设计思路是每一层都用最小依赖实现一个可运行的版本然后再往上叠加复杂度。具体来说分为五个层次基础数值计算层、模型推理层、文本处理层、检索增强层、应用编排层。每一层都有明确的输入输出接口层与层之间通过约定好的数据结构通信。这样做的好处是任何一层出问题你都可以单独替换或调试而不会牵一发而动全身。2.2 技术选型的核心考量依赖越少理解越深在技术选型上我有一个非常固执的原则能用标准库解决的绝不引入第三方依赖能用纯Python实现的绝不调用C扩展。这不是为了炫技而是为了让你能真正看懂每一行代码在做什么。比如矩阵运算我选择用NumPy而不是PyTorch因为NumPy的API更接近数学定义你能清楚地看到每一个维度变换。等到你完全理解了前向传播的每一个步骤再去用PyTorch的自动求导和GPU加速就会觉得理所当然。再比如文本切分很多人直接上LangChain的RecursiveCharacterTextSplitter但你知道它内部的切分优先级是怎么定的吗它先按段落切再按句子切最后按字符切每一层都有重叠窗口。如果你自己实现一遍就会明白重叠窗口的大小直接影响到检索时上下文是否完整而chunk_size和chunk_overlap这两个参数的设置其实和你的文档类型、Embedding模型的最大输入长度都有关系。推理服务这块我选择用FastAPI而不是Flask原因很简单FastAPI原生支持异步和Pydantic数据校验这两点在AI服务里太重要了。一次推理请求可能耗时几秒甚至几十秒如果用同步框架并发能力会惨不忍睹。而Pydantic能帮你在请求入口就把参数格式校验好避免脏数据进入推理逻辑。至于向量数据库初期我建议直接用NumPy做暴力检索数据量超过十万条再考虑上FAISS或者Milvus因为过早引入向量数据库只会增加你的调试复杂度。2.3 分层架构的接口设计与数据流转整个系统的数据流转是这样的用户输入一段文本先经过文本处理层做清洗和切分然后送入模型推理层做Embedding或者生成检索增强层根据Embedding结果去向量库里找相关文档最后应用编排层把检索结果和原始问题组装成Prompt再调用生成模型输出最终答案。每一层的输出都是下一层的输入接口定义清晰之后每一层都可以独立测试。我特别想强调一点接口设计要面向数据结构而不是面向具体实现。比如文本处理层的输出我定义为一个包含chunk_id、text、metadata的字典列表而不是直接返回一个字符串列表。这样做的原因是后续检索和排序都需要用到metadata里的信息比如文档来源、页码、时间戳等。如果你一开始只返回纯文本后面想加元数据就得改一大片代码。这种“面向数据结构设计接口”的思路是我在多个项目中总结出来的血泪教训。另外关于配置管理我建议把所有可调参数集中放在一个配置文件里而不是散落在各个模块中。比如chunk_size、embedding_dim、top_k、temperature这些参数统一放在config.yaml里代码里通过一个配置加载器读取。这样做的好处是调参的时候不用满项目找变量而且方便做实验对比。我通常会为每一组参数组合记录一次实验结果包括检索命中率、生成质量评分、平均响应时间等形成一张实验记录表。3. 核心细节解析与实操要点3.1 文本切分RAG效果的地基文本切分是RAG系统里最容易被忽视、却最影响效果的环节。我见过太多项目Embedding模型用的是顶配向量库用的是分布式集群结果检索出来的内容驴唇不对马嘴根本原因就是切分没做好。切分的核心目标只有一个保证每一个chunk在语义上是完整的同时长度不超过Embedding模型的最大输入限制。具体怎么做我的经验是采用“三级切分”策略。第一级按文档的自然结构切比如Markdown的标题、PDF的章节、HTML的段落标签。第二级按句子切中文用句号、问号、感叹号、分号英文用句点、问号、感叹号。第三级按字符切作为兜底方案确保不会出现超长chunk。每一级切分之后都要检查chunk长度如果超过阈值就进入下一级切分。这里有一个关键参数chunk_overlap。它的作用是让相邻chunk之间有一段重叠文本避免一个完整的语义单元被硬生生切断。比如一句话正好跨在两个chunk的交界处如果没有重叠这句话就会被拆成两半检索时两个chunk都只能匹配到半句话相关性评分都会偏低。重叠窗口的大小一般设置为chunk_size的10%到20%。我实测下来对于技术文档15%的重叠比例效果比较均衡对于对话记录20%的重叠比例能更好地保留上下文。还有一个容易被忽略的细节元数据的保留。每个chunk都必须携带来源信息比如文档ID、章节标题、页码、创建时间等。这些元数据在检索阶段可以用来做过滤比如只检索某个时间范围之后的文档或者只检索某个章节的内容。我在一个法律咨询项目里就吃过亏初期没保留条款编号后来用户要求“只引用第三章的内容”不得不重新处理整个文档库。def split_text(text, chunk_size500, overlap75): # 第一级按段落切分 paragraphs text.split(\n\n) chunks [] current_chunk for para in paragraphs: if len(current_chunk) len(para) chunk_size: current_chunk para \n\n else: if current_chunk: chunks.append(current_chunk.strip()) # 第二级段落本身超长按句子切 if len(para) chunk_size: sentences split_sentences(para) temp for sent in sentences: if len(temp) len(sent) chunk_size: temp sent else: chunks.append(temp.strip()) temp sent if temp: chunks.append(temp.strip()) current_chunk else: current_chunk para \n\n if current_chunk: chunks.append(current_chunk.strip()) # 第三级添加重叠窗口 final_chunks [] for i, chunk in enumerate(chunks): if i 0: prev_tail chunks[i-1][-overlap:] chunk prev_tail chunk final_chunks.append(chunk) return final_chunks上面这段代码就是三级切分的核心逻辑。注意第三级添加重叠窗口时我是把上一个chunk的尾部拼接到当前chunk的头部而不是反过来。这样做的好处是当前chunk的起始位置有了上文信息检索时更容易匹配到跨chunk的语义。但要注意拼接之后chunk长度会变长所以chunk_size要预留出overlap的空间否则可能超过Embedding模型的最大输入长度。3.2 向量化与相似度计算别被“余弦相似度”绑架向量化就是把文本变成一串浮点数这串浮点数要能表征文本的语义。早期我用的是TF-IDF后来换成Word2Vec再后来是BERT类模型现在主流是各种Embedding API。不管用什么方法核心都是把语义相近的文本映射到向量空间中距离相近的位置。这里有一个常见的误区很多人默认用余弦相似度却不知道它和欧氏距离在什么情况下等价、什么情况下不等价。简单来说如果所有向量都做了L2归一化那么余弦相似度和欧氏距离是单调等价的排序结果完全一样。但如果向量没有归一化余弦相似度只看向量方向欧氏距离还看向量长度两者可能给出不同的排序。我在实际项目中一般会先做L2归一化然后用点积计算相似度因为点积在NumPy里就是一行np.dot速度最快。另一个关键问题是维度选择。Embedding维度越高表达能力越强但存储和计算成本也越高。常见的维度有384、768、1024、1536。我的经验是对于短文本检索比如问答对384维足够对于长文档检索768维起步如果要做细粒度的语义匹配1024维以上更稳妥。但维度不是越高越好我做过一组对比实验在同一个数据集上768维和1536维的检索命中率差异不到2%但存储成本翻了一倍。所以选维度的时候要综合考虑效果和成本。import numpy as np def normalize(vectors): norms np.linalg.norm(vectors, axis1, keepdimsTrue) return vectors / (norms 1e-10) def cosine_similarity(query_vec, doc_vecs): query_norm query_vec / (np.linalg.norm(query_vec) 1e-10) doc_norms normalize(doc_vecs) return np.dot(doc_norms, query_norm) def top_k_retrieval(query_vec, doc_vecs, k5): scores cosine_similarity(query_vec, doc_vecs) top_indices np.argsort(scores)[::-1][:k] return top_indices, scores[top_indices]上面这段代码展示了归一化、相似度计算和TopK检索的完整流程。注意1e-10这个极小值是为了防止除以零。在实际项目中我还遇到过向量全为零的情况这时候归一化会出问题所以最好在向量化之后加一个检查如果向量模长小于某个阈值就标记为无效向量不参与检索。3.3 推理服务的并发模型同步阻塞是性能杀手当你把模型推理封装成HTTP服务时并发模型的选择直接决定了服务的吞吐量。我见过很多项目用Flask写推理接口然后发现并发一高就排队原因就是Flask默认是同步阻塞的一个请求在推理的时候整个线程都被占住其他请求只能等着。正确的做法是用异步框架比如FastAPI配合async/await。但这里有一个坑模型推理本身通常是CPU或GPU密集型的同步操作直接放在async函数里会阻塞事件循环。解决办法是用run_in_executor把推理操作放到线程池里执行这样事件循环可以继续处理其他请求。如果你的推理框架本身支持异步比如某些推理引擎提供了异步接口那就更好了。from fastapi import FastAPI from concurrent.futures import ThreadPoolExecutor import asyncio app FastAPI() executor ThreadPoolExecutor(max_workers4) def sync_inference(text): # 这里是同步的模型推理逻辑 return model.generate(text) app.post(/generate) async def generate(request: GenerateRequest): loop asyncio.get_event_loop() result await loop.run_in_executor(executor, sync_inference, request.text) return {result: result}线程池的大小需要根据你的硬件来定。如果是CPU推理max_workers一般设置为CPU核心数如果是GPU推理max_workers设置为1到2就够了因为GPU本身是串行执行核函数的开太多线程反而会增加上下文切换开销。我实测下来单张消费级显卡跑7B模型max_workers1时吞吐量最高因为推理时间远大于线程切换时间。还有一个细节请求队列的管理。当并发请求超过线程池容量时多余的请求会排队等待。如果队列太长用户等待时间会很长甚至超时。我的做法是设置一个最大队列长度超过就直接返回“服务繁忙”的提示而不是让用户无限等待。这个最大队列长度可以通过压测来确定一般设置为线程池大小的2到3倍比较合适。3.4 Prompt组装把检索结果变成模型能理解的输入检索到相关文档之后下一步是把它们和用户问题组装成Prompt送给生成模型。这一步看似简单其实有很多讲究。首先是文档的排序我一般会把相似度最高的文档放在最前面因为很多模型对开头和结尾的内容注意力更集中中间部分容易被忽略。其次是文档的截断如果检索到的文档总长度超过了模型的最大上下文长度就需要截断。截断的策略是从相似度最低的文档开始删直到总长度符合要求。Prompt模板的设计也很关键。我常用的模板是这样的你是一个知识助手请根据以下参考资料回答用户问题。 如果参考资料中没有相关信息请如实告知不要编造。 参考资料 [1] {doc_1} [2] {doc_2} ... 用户问题{question} 请给出回答这个模板有几个设计点第一明确告诉模型“根据参考资料回答”避免它自由发挥第二加了“不要编造”的约束减少幻觉第三给每篇文档编号方便在回答中引用来源。我实测下来加了编号之后模型引用来源的准确率明显提升用户也能追溯答案的出处。还有一个进阶技巧在Prompt里加入对话历史。如果是多轮对话场景需要把之前的对话轮次也拼接到Prompt里。但要注意对话历史会占用大量Token所以一般只保留最近3到5轮。我通常会把对话历史放在参考资料之前让模型先理解上下文再看参考资料。4. 实操过程与核心环节实现4.1 环境准备与依赖安装整个项目我建议用Python 3.10以上版本因为很多新特性比如模式匹配、更好的类型提示能提升开发效率。依赖方面核心只有几个NumPy用于数值计算FastAPI和Uvicorn用于推理服务Pydantic用于数据校验PyYAML用于配置文件解析。如果你要用真实的Embedding模型还需要安装sentence-transformers或者直接调用某个Embedding API。python -m venv venv source venv/bin/activate # Windows用 venv\Scripts\activate pip install numpy fastapi uvicorn pydantic pyyaml sentence-transformers这里我想提醒一点不要一上来就装一堆依赖。我见过有人把LangChain、LlamaIndex、Transformers、Accelerate全装了一遍结果环境冲突搞了半天。正确的做法是按需安装用到什么装什么。而且每装一个依赖都要想清楚它解决了什么问题能不能用更轻量的方案替代。比如文本切分自己写几十行代码就能搞定没必要引入一个几百KB的库。4.2 配置文件的设计与参数计算配置文件我一般用YAML格式因为可读性好支持嵌套结构。下面是一个典型的配置示例text_split: chunk_size: 500 chunk_overlap: 75 min_chunk_size: 50 embedding: model_name: all-MiniLM-L6-v2 dimension: 384 batch_size: 32 retrieval: top_k: 5 score_threshold: 0.3 generation: max_context_length: 2048 temperature: 0.7 max_new_tokens: 512 server: host: 0.0.0.0 port: 8000 max_workers: 4 max_queue_size: 12这里重点说几个参数的计算过程。chunk_size设为500是因为我用的Embedding模型最大输入长度是512个Token中文平均一个字符对应1.5个Token左右所以500个字符大约对应750个Token超过了模型限制。等等这里有个矛盾如果模型最大输入是512个Token那chunk_size应该设得更小才对。实际上all-MiniLM-L6-v2的最大输入长度是256个Token所以chunk_size应该设为150到200个中文字符。我上面写500是为了举例实际项目中一定要根据模型的实际限制来算。正确的计算方法是先查Embedding模型的最大输入Token数然后估算你的文本中Token和字符的比例。英文大约是1个Token对应4个字符中文大约是1个Token对应1.5到2个字符。用最大Token数乘以字符比例再留出20%的安全余量就是chunk_size的上限。比如模型最大输入256个Token中文比例按1.5算256乘以1.5等于384再乘以0.8等于307所以chunk_size设为300比较安全。top_k设为5是因为我做过实验在大多数问答场景下Top5的检索结果已经能覆盖90%以上的相关信息再增加TopK数量召回率提升有限但Prompt长度和推理时间会明显增加。score_threshold设为0.3是为了过滤掉明显不相关的文档。如果所有文档的相似度都低于0.3说明知识库里没有相关内容这时候应该让模型直接回答“我不知道”而不是强行拼凑。4.3 完整推理链路的代码实现下面我把从文本输入到答案输出的完整链路串起来。首先是文本处理模块import re def clean_text(text): text re.sub(r\s, , text) text re.sub(r[^\w\s\u4e00-\u9fff。、《》], , text) return text.strip() def split_sentences(text): pattern r(?[。])|(?[.!?;])\s sentences re.split(pattern, text) return [s.strip() for s in sentences if s.strip()]clean_text函数做了两件事把连续空白字符合并成一个空格去掉特殊符号但保留中文标点和常用标点。这里要注意不要过度清洗比如把换行符全删了因为换行符有时候是段落分隔的重要标志。我的做法是先把连续空白合并再根据后续切分策略决定是否保留换行。接下来是向量化模块from sentence_transformers import SentenceTransformer class EmbeddingModel: def __init__(self, model_name, batch_size32): self.model SentenceTransformer(model_name) self.batch_size batch_size def encode(self, texts): if isinstance(texts, str): texts [texts] embeddings self.model.encode( texts, batch_sizeself.batch_size, normalize_embeddingsTrue, show_progress_barFalse ) return embeddings注意normalize_embeddingsTrue这个参数它会在编码时自动做L2归一化这样后续计算相似度直接用点积就行不用再手动归一化。batch_size设为32是因为我在测试中发现批量编码比逐条编码快3到5倍但batch_size太大又会增加内存占用32是一个比较均衡的值。然后是检索模块class VectorStore: def __init__(self, dimension): self.dimension dimension self.vectors np.zeros((0, dimension), dtypenp.float32) self.metadata [] def add(self, vectors, metadata_list): self.vectors np.vstack([self.vectors, vectors]) self.metadata.extend(metadata_list) def search(self, query_vector, top_k5, threshold0.3): if len(self.vectors) 0: return [] scores np.dot(self.vectors, query_vector) top_indices np.argsort(scores)[::-1][:top_k] results [] for idx in top_indices: if scores[idx] threshold: results.append({ score: float(scores[idx]), metadata: self.metadata[idx] }) return results这个VectorStore类用NumPy数组存储所有向量检索时做全量点积计算。数据量小的时候几万条以内这种暴力检索完全够用而且实现简单没有额外依赖。等到数据量超过十万条再考虑换成FAISS或者Milvus。我特别建议在项目初期用这种朴素实现因为你能清楚地看到检索的每一步出了问题也容易排查。最后是生成模块和FastAPI接口from fastapi import FastAPI, HTTPException from pydantic import BaseModel import yaml app FastAPI() class QueryRequest(BaseModel): question: str top_k: int 5 class QueryResponse(BaseModel): answer: str sources: list app.post(/query, response_modelQueryResponse) async def query(request: QueryRequest): # 1. 清洗问题 question clean_text(request.question) # 2. 向量化 query_vec embedding_model.encode(question)[0] # 3. 检索 results vector_store.search(query_vec, top_krequest.top_k) if not results: return QueryResponse(answer抱歉知识库中没有找到相关信息。, sources[]) # 4. 组装Prompt context \n\n.join([f[{i1}] {r[metadata][text]} for i, r in enumerate(results)]) prompt f根据以下参考资料回答问题\n\n{context}\n\n问题{question}\n\n回答 # 5. 生成 answer generate_answer(prompt) # 6. 返回 sources [{text: r[metadata][text][:100], score: r[score]} for r in results] return QueryResponse(answeranswer, sourcessources)这个接口把整个链路串起来了。注意我在返回结果里加了sources字段把检索到的文档片段和相似度分数一起返回给前端。这样做的好处是用户可以看到答案的依据增加信任感。而且如果答案有问题开发者也能快速定位是检索环节还是生成环节出了错。4.4 性能压测与参数调优实录代码写完之后一定要做压测。我用的是locust这个工具模拟并发请求观察响应时间和吞吐量。第一次压测的结果很不理想10个并发用户平均响应时间3.2秒P99响应时间8.7秒。分析下来瓶颈在Embedding编码环节因为每次请求都要重新编码问题文本而模型加载和推理本身耗时较长。优化方案有两个第一对常见问题做缓存如果同一个问题之前问过直接返回缓存结果第二把Embedding模型换成更小的模型牺牲一点精度换速度。我选择了第一个方案用Python的functools.lru_cache做了一层简单的缓存。优化之后重复问题的响应时间降到了50毫秒以内新问题的响应时间还是3秒左右但整体吞吐量提升了一倍多。还有一个优化点是批量处理。如果多个请求同时到达可以把它们的问题文本合并成一个批次一次性编码这样能充分利用GPU的并行能力。但批量处理会增加单个请求的等待时间因为要等批次凑满。我的做法是设置一个10毫秒的等待窗口窗口内的请求合并成一个批次窗口外的请求单独处理。这个策略在并发量中等每秒10到50个请求的场景下效果最好。5. 常见问题与排查技巧实录5.1 检索结果不相关从切分到模型逐层排查检索结果不相关是最常见的问题排查思路是从后往前查。先看检索到的文档本身是否相关如果不相关再看向量化结果是否合理最后看切分是否破坏了语义。第一步打印出检索到的TopK文档原文人工判断是否和问题相关。如果明显不相关进入第二步。第二步把问题和文档的向量拿出来计算它们之间的相似度看看是否和预期一致。如果相似度很低但人工判断应该相关说明Embedding模型不适合你的领域数据需要换模型或者做微调。第三步检查切分后的chunk是否语义完整。我遇到过一个案例用户问“如何申请退款”检索到的文档是“退款流程分为三步第一步...”但chunk切分把“退款流程分为三步”和“第一步...”切到了两个不同的chunk里导致检索时只匹配到了后半部分相关性评分偏低。解决切分问题的方法很简单调整chunk_size和chunk_overlap或者改用按语义切分的策略。我一般会先用固定参数跑一遍然后人工检查20到30个chunk的边界看看有没有明显的语义截断。如果有就调整参数重新切分。这个工作看起来繁琐但一次调整好之后后续所有文档都能受益。5.2 生成答案胡编乱造幻觉的成因与抑制幻觉是生成模型的固有缺陷完全消除是不可能的但可以通过多种手段抑制。最有效的手段是在Prompt里明确约束“如果参考资料中没有相关信息请回答不知道”。我实测下来加了这句话之后幻觉率能降低60%以上。第二个手段是降低temperature参数。temperature控制生成的随机性值越高越有创造性但也越容易胡编。对于知识问答场景我一般把temperature设为0.1到0.3让模型尽量选择概率最高的词减少随机发挥。但temperature也不能设得太低否则回答会变得非常死板甚至出现重复循环。第三个手段是后验证。生成答案之后用另一个模型或者规则检查答案中的关键信息是否能在参考资料中找到依据。比如答案里提到了某个数字就去参考资料里搜索这个数字如果找不到就标记为可疑。这个方法会增加一些计算成本但对于准确性要求高的场景比如医疗、法律非常值得。还有一个容易被忽略的点参考资料的质量。如果检索到的文档本身就是错误的或者过时的模型基于它生成的答案自然也是错的。所以知识库的维护和更新同样重要。我建议定期对知识库做质量抽检把过时、错误、重复的文档清理掉。5.3 服务响应慢从模型加载到网络传输的全链路优化响应慢的问题可能出在任何一个环节我一般按照以下顺序排查排查环节可能原因排查方法优化方案模型加载每次请求都重新加载模型看日志里模型加载耗时服务启动时加载一次常驻内存Embedding编码模型太大或批量太小单独测试编码耗时换小模型或增大batch_size向量检索数据量大且用暴力检索测试检索耗时换FAISS或减少检索范围生成推理生成长度太长或模型太大测试生成耗时限制max_new_tokens或换小模型网络传输返回数据太大看响应体大小只返回必要字段压缩传输我遇到过一个典型案例服务响应时间突然从2秒涨到10秒排查发现是知识库文档数量从1万涨到了50万暴力检索的耗时从几毫秒涨到了几百毫秒。虽然几百毫秒不是主要瓶颈但它叠加在Embedding和生成耗时上让整体响应时间明显变长。后来换成FAISS索引检索耗时降到了1毫秒以内。还有一个常见问题是冷启动。服务刚启动时模型还没加载到内存第一批请求会特别慢。解决办法是在服务启动后先跑几个预热请求让模型完成加载和初始化。我一般在FastAPI的startup事件里做预热这样服务正式对外提供时就已经是热状态了。5.4 显存不足模型加载与推理的显存管理显存不足通常发生在加载大模型或者处理长文本时。首先算一笔账一个7B参数的模型如果用FP16精度加载需要大约14GB显存7B乘以2字节。如果还要做推理中间激活值还需要额外显存具体大小和批次大小、序列长度有关。序列长度翻倍激活值显存大约翻四倍因为注意力矩阵是平方级的。如果显存不够有几个方案第一用量化技术把FP16换成INT8或INT4显存占用能降到原来的二分之一到四分之一但精度会有一定损失。第二用CPU卸载把部分层放到CPU内存里需要的时候再加载到GPU但这样会显著增加推理时间。第三减小批次大小和序列长度这是最直接的方法但会影响吞吐量和能处理的最大文本长度。我个人的经验是对于7B模型至少需要16GB显存才能比较流畅地跑起来对于13B模型需要24GB以上对于70B模型单卡基本跑不动需要多卡或者量化。选模型的时候一定要先看显存需求不要盲目追求大模型。很多时候一个经过良好微调的7B模型在特定任务上的表现不输于未微调的13B模型。5.5 常见问题速查表问题现象可能原因快速验证方法解决方案检索结果不相关切分不合理或模型不匹配人工检查chunk边界调整chunk_size或换Embedding模型答案胡编乱造Prompt约束不足或temperature过高检查Prompt和temperature加“不知道”约束降低temperature响应时间过长模型加载、编码、检索或生成慢分段计时缓存、换小模型、换索引显存不足模型太大或序列太长看显存占用量化、减小批次、换小模型服务并发低同步阻塞或线程池太小压测看排队情况用异步框架调大线程池答案重复循环temperature太低或重复惩罚不足检查生成参数适当提高temperature加重复惩罚中文乱码编码问题检查文件编码和HTTP头统一用UTF-8编码向量检索全为零向量未归一化或模型输出异常检查向量模长加归一化检查模型输出这张表是我在实际项目中总结出来的基本上覆盖了80%以上的常见问题。遇到问题时先对照这张表快速定位然后再深入排查。我建议你把这张表打印出来贴在工位上排查问题时能省不少时间。6. 我个人的一些实操体会做AI工程这几年我最大的体会是框架是拿来用的不是拿来信的。任何框架都有它的设计假设和适用边界如果你不理解这些假设就很容易在边界之外踩坑。比如某个向量数据库默认用欧氏距离但你的向量没有归一化检索结果就会和预期不一致。如果你自己实现过一遍相似度计算就能立刻意识到问题所在。另一个体会是参数没有绝对的最优值只有最适合当前场景的值。网上很多教程会告诉你chunk_size500、top_k5、temperature0.7但这些值放到你的数据上可能完全不适用。我建议你在项目初期花一点时间做参数扫描把关键参数的不同取值组合都跑一遍记录下效果指标然后选一组最合适的。这个工作看起来费时间但比后面反复调参要高效得多。最后分享一个小技巧给每个chunk加一个唯一ID并且在日志里记录每次检索命中的chunk ID。这样当用户反馈答案有问题时你可以快速定位到是哪个chunk被检索到了然后检查这个chunk的内容和切分边界。这个习惯帮我节省了大量排查时间强烈推荐你也这么做。
返回列表