ARTICLE DETAIL

资讯详情

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

从零搭建AI工程:架构设计、RAG链路与提示词管理实战

从零搭建AI工程:架构设计、RAG链路与提示词管理实战 1. 项目概述“AI engineering from scratch”直译过来就是“从零开始做AI工程”。这个标题你乍一看可能觉得有点大、有点空但拆开来看其实它是很多开发者在当下的节点都会遇到的一个真实命题工具链满天飞、框架一个月迭代三个版本、模型一个星期换一代人人都说“AI工程化”但真正落到自己手上从环境搭建、代码结构、数据流转到上线监控到底该怎么一步一步搭起来我最早做这个项目是因为团队里接了一个内部知识库问答的需求。需求本身不复杂把文档切片、做向量化、接大模型、开一个聊天界面。但真的动手之后才发现最难的从来不是调一个API而是怎么把“调API”稳定地变成一套可以维护、可以测试、可以迭代的工程体系。模型输出不稳定怎么办提示词改了旧结果复现不了怎么办Token开销怎么监控代码里到处都是大模型调用的硬编码怎么解耦这篇文章我不会去讲“AI行业的未来”也不讲“大模型原理剖析”就讲实实在在的工程落地。我会把当时从零搭建AI工程项目的完整路径拆给你看项目结构怎么设计、依赖怎么管理、数据层和模型层怎么解耦、提示词怎么做到可版本化、观测和测试怎么做、最后怎么部署上线。全程是实操记录每一条都是我真刀真枪跑过、踩过坑之后的经验。适合谁看适合准备做AI应用但还没想清楚工程架构的开发者也适合已经在调API、但代码越写越乱、想系统性重构的人。你不是需要另一个教程你需要一套能直接抄作业的工程骨架这篇文章给的就是这个。2. 为什么“从零开始”反而是最优解2.1 别急着上全家桶框架现在的AI开发框架多到让人选择困难有帮你编排Agent的、有帮你管理提示词的、有帮你自动做RAG管线的甚至还有拖拽式的工作流平台。我一开始也动过这个念头想着上个全家桶省事。结果试用了一圈下来发现一个共性问题框架帮你封装得越多你离底层真相就越远。出问题的时候排查链路长到你根本不知道是框架的bug、模型的输出问题、还是你自己的业务逻辑问题。从零开始搭不是说让你连大模型的SDK都自己写而是指你的工程骨架从最基础的文件结构、依赖管理、模块划分开始由你自己一步步构造。这个过程看似“慢”实际上是在给你的项目建立一套你完全可控的底层逻辑。就像盖房子你可以买预制板但地基的承重结构你最好清楚是怎么打的。而且从零开始还有一个隐性的好处它逼你想清楚每一层是干什么的。很多项目用框架跑起来特别快但过两周你回去看自己写的代码根本说不清“这段逻辑为什么在这里”。从零搭一遍你会对每一块代码负责项目的演进路径也会更健康。2.2 一套清晰的AI工程全景我在项目初始阶段画过一张简单的脑图来梳理整个链路这里没有花哨的架构图就用文字给你描述清楚输入层用户提问、解析层意图识别与参数抽取、检索层如果需要RAG就做文档召回、模型调用层统一封装大模型接口、输出层后处理与格式化、观测层日志、Token统计、错误追踪、持久层对话记录与向量库再加上贯穿始终的配置与测试。这个链路看起来简单但它解决了几个真正会让人头疼的问题第一每一层都可以独立替换今天用OpenAI明天想换国产模型只动模型调用层第二每一层都有明确的输入输出结构前后联动靠接口契约而不是靠乱传字典第三出问题的时候你可以顺着链路一层一层排查非常快就能定位到是检索没召回到内容还是模型没遵守输出格式。这个项目标题里的“from scratch”在我看来指的就是把上面这条链路一块砖一块砖地自己砌起来。而不是下载一个ChatGPT套壳模板改个名就上线。3. 第一步工程初始化——从建目录到锁依赖3.1 项目结构设计与Python环境准备项目初始阶段Python版本和包管理器是第一道关卡。我当时用的是Python 3.11但这个版本选择本身不是盲目的3.11在asyncio并发模型的性能上有明显提升同时主流AI SDK比如OpenAI Python SDK、LangChain核心库对3.11的兼容性已经非常成熟。如果你用的Python 3.10或3.12也问题不大但整个项目最好统一锁版本避免不同开发机之间因为解释器版本差异跑出不一样的结果。包管理器这个点我强烈建议你用uv。它不是传统意义的pip替代品而是Rust写的极速包管理器解决依赖的并行解析和全局缓存在项目依赖多且需要反复安装的场景下速度优势是碾压级的。具体到操作层面你用uv创建虚拟环境远比手动python -m venv加上后续activate来得顺手它的命令写起来很像现代Rust/Cargo的体验。初始化核心命令建议记录一下# 安装uv curl -LsSf https://astral.sh/uv/install.sh | sh # 初始化项目环境 uv init ai-engineering-from-scratch cd ai-engineering-from-scratch # 创建Python 3.11的虚拟环境 uv venv .venv --python 3.11 # 激活环境macOS/Linux source .venv/bin/activate # 一步步添加核心依赖 uv add openai python-dotenv pydantic pydantic-settings uv add --dev pytest ruff mypy typesheduv最有价值的优势在于它会生成一个uv.lock文件自动锁定全链路依赖的精确版本。这样团队协作时每个人pull代码后跑一次uv sync就能把依赖还原到和CI环境完全一致的状态再也不会出现“我本地能跑你那边报错”的经典甩锅局。项目目录结构我建议这样搭ai-engineering-from-scratch/ ├── src/ │ └── ai_eng/ │ ├── __init__.py │ ├── config.py # 配置文件加载 │ ├── models/ │ │ ├── __init__.py │ │ ├── schemas.py # Pydantic数据模型 │ │ └── llm.py # 大模型调用封装 │ ├── agents/ │ │ ├── __init__.py │ │ └── router.py # 意图路由 │ ├── rag/ │ │ ├── __init__.py │ │ ├── loader.py # 文档加载 │ │ ├── splitter.py # 文本切分 │ │ └── store.py # 向量库交互 │ ├── prompts/ │ │ ├── __init__.py │ │ └── catalog.py # 提示词目录 │ ├── services/ │ │ ├── __init__.py │ │ └── chatbot.py # 核心对话服务 │ └── utils/ │ ├── __init__.py │ ├── logger.py # 日志封装 │ └── retry.py # 重试机制 ├── tests/ │ ├── __init__.py │ ├── test_llm.py │ ├── test_router.py │ └── test_chatbot.py ├── data/ │ ├── raw/ # 原始文档 │ ├── processed/ # 切分后文本 │ └── vector_store/ # 本地向量库持久化 ├── logs/ ├── pyproject.toml ├── uv.lock ├── .env.example └── README.md这个结构初看可能觉得有点过度设计但它在实践中会带来几个立竿见影的好处第一src下按功能模块划分每个模块之间有清晰的边界不会出现一个chatbot.py里塞了800行、什么都干的情况第二tests目录从一开始就在逼着你在写功能的时候就考虑怎么写测试第三prompts目录独立出来以后提示词有改动直接看diff就能定位问题。3.2 环境变量与配置管理的严谨之道AI项目的配置管理是个大坑。很多人图省事直接把API Key写在代码里或者把模型名、温度参数这些散落在各个文件的魔法变量里。我对配置管理的要求很简单代码里不应该出现任何与运行环境相关的硬编码。为此我用pydantic-settings做一个配置类让所有变量集中管理并且从环境变量里自动读取。先写配置文件src/ai_eng/config.pyfrom pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config SettingsConfigDict( env_file.env, env_file_encodingutf-8, extraignore ) # 基础配置 app_name: str ai-engineering-bootcamp log_level: str INFO # 模型配置 llm_provider: str openai llm_model: str gpt-4o-mini llm_temperature: float 0.2 llm_max_tokens: int 1024 llm_timeout: float 30.0 # 检索配置 rag_top_k: int 5 rag_chunk_size: int 800 rag_chunk_overlap: int 150 # 向量库配置 vector_store_path: str data/vector_store # Redis缓存可选 redis_url: str | None None def get_settings() - Settings: return Settings()注意我设置了extraignore这样当.env文件里有一些冗余变量时不会报错。同时所有配置都有默认值这意味着你本地开发时即使不配.env也能跑起来一个最小可用的样例。生产环境中再通过注入真实环境变量的方式来覆盖默认值。配套的.env.example是这样# 复制为 .env 并填入真实值 LLM_PROVIDERopenai LLM_MODELgpt-4o-mini LLM_TEMPERATURE0.2 LLM_MAX_TOKENS1024 RAG_TOP_K5这套做法的核心价值不是“规范”而是可复现任何人拿到你的项目按.env.example填上自己的Key马上就能跑通同一套行为。后续上容器、上K8s环境变量的注入路径也是现成的。4. 第二步模型接入与统一封装——别让API绑架你的代码4.1 用Provider模式做LLM调用适配层大模型行业现在的格局大家都清楚模型厂商一个接一个模型能力迭代速度极快。如果代码里到处是openai.ChatCompletion.create(...)这种硬调用哪天你想换一个更便宜或者效果更好的模型面临的就是全局搜索替换的重构地狱。最好的解法是引入一个Provider接口层设计上类似“适配器模式”。业务逻辑只跟你的Provider接口打交道具体是OpenAI、Claude还是本地Ollama由工厂函数根据配置创建。先定义一个协议类from abc import ABC, abstractmethod from pydantic import BaseModel class ChatMessage(BaseModel): role: str content: str class LLMResponse(BaseModel): content: str prompt_tokens: int completion_tokens: int total_tokens: int model: str latency_ms: int class LLMProvider(ABC): abstractmethod def chat(self, messages: list[ChatMessage], **kwargs) - LLMResponse: 发送消息序列返回模型回复及用量统计 pass然后是OpenAI的实现from openai import OpenAI from ai_eng.config import get_settings from ai_eng.models.llm import LLMProvider, ChatMessage, LLMResponse class OpenAIProvider(LLMProvider): def __init__(self, settings): self.client OpenAI( api_keysettings.llm_api_key, timeoutsettings.llm_timeout, max_retries2, ) self.model settings.llm_model self.temperature settings.llm_temperature self.max_tokens settings.llm_max_tokens def chat(self, messages: list[ChatMessage], **kwargs) - LLMResponse: import time start time.monotonic() api_messages [{role: m.role, content: m.content} for m in messages] try: resp self.client.chat.completions.create( modelself.model, messagesapi_messages, temperaturekwargs.get(temperature, self.temperature), max_tokenskwargs.get(max_tokens, self.max_tokens), ) usage resp.usage return LLMResponse( contentresp.choices[0].message.content or , prompt_tokensusage.prompt_tokens if usage else 0, completion_tokensusage.completion_tokens if usage else 0, total_tokensusage.total_tokens if usage else 0, modelself.model, latency_msint((time.monotonic() - start) * 1000), ) except Exception as e: # 这里可以选择打日志或者向上抛自定义异常 raise e再做一个工厂函数根据配置返回对应实现def create_llm_provider(settings: Settings) - LLMProvider: if settings.llm_provider openai: return OpenAIProvider(settings) if settings.llm_provider ollama: return OllamaProvider(settings) raise ValueError(fUnsupported provider: {settings.llm_provider})这样业务代码从始至终只依赖LLMProvider这个抽象不管底层换了什么模型厂商对上层来说都是透明的。这一点在AI项目里尤其重要因为模型层的变更频率一定是全项目最高的。4.2 一个拦在“AI黑盒”前面的强类型屏障大模型返回的东西天然是“无结构文本”这既是它灵活的原因也是它灾难的源头。如果直接把模型输出丢回给前端或者业务逻辑你的系统就会像一个没有接口契约的服务一样随时可能因为多了一个换行、少了一个括号而挂掉。工程上正确的做法是对模型的输出做结构化约束。这里有两个层次第一层是在写提示词时明确要求模型输出JSON并在提示词里定义好字段结构第二层是拿到模型返回后用Pydantic做运行时校验。不要把校验的责任丢给提示词提示词只是降低错误率的辅助手段Pydantic才是真正的防线。举例来说当你做一个意图路由模块你希望模型输出“是/否”或者“分类标签”可以这样定义输出模型from pydantic import BaseModel, Field class IntentResult(BaseModel): intent: str Field(description意图分类如chat, knowledge_retrieval, image_generation) confidence: float Field(ge0.0, le1.0, description置信度) requires_rag: bool Field(description是否需要检索增强) rewritten_query: str | None Field(defaultNone, description意图改写后的检索问法)然后用一个简单的JSON解析函数做安全校验import json from pydantic import ValidationError from ai_eng.models.schemas import IntentResult def parse_model_json(text: str) - IntentResult: cleaned text.strip() # 防止模型输出包含代码围栏 if cleaned.startswith(): cleaned cleaned.strip() if cleaned.startswith(json): cleaned cleaned[4:] try: data json.loads(cleaned) return IntentResult(**data) except (json.JSONDecodeError, ValidationError) as e: # 记录原始输出方便排查提示词问题 raise ValueError(f模型结构化输出解析失败: {e}, 原始文本: {text[:200]})强类型的好处不用多说下游逻辑不需要关心字符串怎么拆怎么匹配拿到的是一个有类型、有字段校验的Python对象。这在做单元测试的时候尤其爽一个字段一个字段地断言模型输出的不确定性被牢牢关在笼子里。5. 第三步数据层与检索——没有RAG的AI工程是不完整的5.1 文档加载与切分的参数设计我做的内部知识库问答核心价值在于“私有知识不再只有检索价值还能直接参与回答”。而RAG链路的第一步是让系统“读得懂”你的文档。这一步看似简单实际上切分参数的设置直接决定了检索质量的生死。文档加载我建议按来源分类来处理PDF用PyMuPDFfitz或者pypdfHTML可以用BeautifulSoup清洗后提取正文Markdown和纯文本直接读。每个加载器都尽量输出统一的Document对象包含page_content和metadata来源路径、页号、标题等。元数据这东西千万别省它在你后面做“按来源过滤检索结果”的时候能帮上大忙。文本切分这个环节是重头戏。切太粗每一块文本包含太多无关信息向量化后语义被稀释检索召回精度下降切太细上下文断裂语义不完整模型拿到的内容又是支离破碎的。我常用的策略是递归字符切分。基本思路设定一个chunk_size如果文本超过这个长度就尝试从最近的段落分隔符处断开如果段落本身超长再退而求其次从句子边界断开实在不行才做硬切。核心参数参考参数推荐值选择理由chunk_size600~1000字符中文场景下一个切片能容纳3~5个完整观点chunk_overlap100~200字符保留上下文连接处语义避免信息断层separators先换行再句号再分号尽量在语义完整处切开length_function中文字符数计按token数切分与国内模型的tokenizer不一致代码示例from typing import List def recursive_split_text(text: str, chunk_size: int 800, overlap: int 150) - List[str]: if len(text) chunk_size: return [text] chunks [] start 0 separators [\n\n, \n, 。, , , , ] while start len(text): end start chunk_size window text[start:end] # 从后往前找分隔符 split_at -1 for sep in separators: idx window.rfind(sep) if idx ! -1 and idx chunk_size * 0.6: split_at start idx len(sep) break if split_at -1: split_at start chunk_size chunks.append(text[start:split_at].strip()) start max(split_at - overlap, start chunk_size // 4) return [c for c in chunks if c]这里有两个细节值得说明第一rfind之所以要求分隔符位置在窗口的60%之后是为了避免切出来的第一个切片过短第二overlap相当于让相邻两个切片共享一段文本这样模型回答跨切片问题时不至于完全丢失衔接信息。你调参的时候不用纠结精确数值不同语料的最佳参数一定不同关键是理解每个参数在决定什么行为。5.2 向量化与向量库的选择心得向量化模型的选择中文场景下我用了好几种。比如openai的text-embedding-3-small通用效果好但如果你私域知识有很强的垂直性比如法律、医学、代码文档强烈建议试试开源的BGE系列模型如BAAI/bge-large-zh-v1.5你也可以通过Ollama本地跑完全离线数据不出内网。向量库这块项目初期数据量不大几万切片以内我倾向于用一个轻量方案Chroma或者LanceDB。Chroma的好处是接口简单可以直接同步到本地文件目录适合单机和初版验证。数据量一旦涨上来再迁移到Milvus或者Qdrant不迟。做工程判断时没必要在第一步就上一套重型的分布式向量数据库因为你连“检索效果到底行不行”都还没验证过。Chroma的基本用法import chromadb from chromadb.utils import embedding_functions # 使用本地embedding模型 ef embedding_functions.OllamaEmbeddingFunction( urlhttp://localhost:11434/api/embeddings, model_namebge-m3, ) client chromadb.PersistentClient(pathdata/vector_store) collection client.get_or_create_collection( nameknowledge_base, embedding_functionef, ) # 写入 collection.add( documents[chunk], metadatas[{source: readme.md, page: 1}], ids[fchunk_{idx}] ) # 检索 results collection.query( query_texts[query], n_results5, include[documents, metadatas, distances], )当时我用的是Ollama本地跑bge-m3 embedding模型好处是彻底摆脱外部API的依赖每次请求的固定成本是零。对内部知识库这种场景这个思路非常合适省Token也没有隐私风险。6. 第四步核心服务逻辑——从“调接口”到“做产品”6.1 意图识别与多轮对话的路由设计真正把AI从一个“玩具”变成一个“服务”核心在于路由设计。你不能让所有用户输入都直接丢给大模型回答“你好”也不能把所有问题都走RAG检索因为很多通用闲聊问题检索来检索去反而干扰模型回答。我的方案是做一个轻量意图识别本质上是一次额外的大模型调用。输入是用户的原始问题和历史会话摘要输出是一个结构化的意图判定。判定逻辑如下INTENT_PROMPT 你是对话系统的意图路由模块。根据用户当前问题和历史对话判断意图分类。 分类选项 - chat: 闲聊、通用对话、问候、情感倾诉 - knowledge: 需要查询知识库的事实性问题 - code: 代码相关问题 - general: 无法确定走通用回答 输出JSON格式 {{intent: 分类, confidence: 0.0到1.0之间的浮点数, requires_rag: true/false, rewritten_query: 如果是知识检索类问题改写为适合检索的中文问句}} 严格要求只输出JSON不要输出任何额外文字。 用户当前问题{user_query} 历史对话最近3轮{history} def detect_intent(user_query: str, history: str) - IntentResult: messages [ ChatMessage(rolesystem, contentINTENT_PROMPT.format( user_queryuser_query, historyhistory )), ChatMessage(roleuser, content请判断意图), ] response llm_provider.chat(messages) return parse_model_json(response.content)有一个非常反直觉的经验我第一次做时也踩了坑如果你让模型直接对“需不需要RAG”做判断它的默认倾向是“需要”因为大模型被训练成乐于提供帮助的模样。所以上面提示词里我把requires_rag跟意图绑定当意图是knowledge时它才为true这样能显著降低误触发检索的比例。6.2 知识检索增强回答的完整链路在判定为knowledge意图后核心对话服务的执行链路是这样的def answer_knowledge_query(user_query: str, history: str) - str: # 1. 改写查询词让检索更精准 rewritten intent_result.rewritten_query or user_query # 2. 向量检索取TopK docs vector_store.query(rewritten, top_kget_settings().rag_top_k) # 3. 拼装上下文 context \n\n.join( f[文档{doc[source]}]{doc[content]} for doc in docs ) # 4. 组装提示词交给大模型综合回答 answer_prompt 你是企业知识库问答助手。基于以下参考文档回答用户问题。 要求 - 优先使用文档内容回答文档没有提到就直接说明不知道 - 不要编造 - 回答最后列出参考文档来源 参考文档 {context} 用户问题{query} messages [ ChatMessage(rolesystem, contentanswer_prompt.format(contextcontext)), ChatMessage(roleuser, contentuser_query) ] response llm_provider.chat(messages) return response.content这里有几个细节很关键越是经验丰富的工程师越会留意第一上下文不要无脑拉满。很多人觉得“把检索到的文档全塞给模型”反正有Token限制。但模型的长文本注意力是会被稀释的我实测下来TopK从10降到5回答准确率反而上升了回答里的废话也变少了。原因是无关文档引入的噪声被排除了。第二提示词里一定要有“不知道就直说”的兜底指令。这听起来像废话但对大模型的回答风格影响巨大。没有这个限定模型会为了“帮助感”而强行编造答案这在企业知识场景里是最致命的错误。第三引用来源不是可选项。每个返回的知识点都要标注它来自哪些文档一方面用户会校验另一方面也是你做错误反馈追踪的锚点。6.3 用提示词目录管理来对抗“提示词地狱”项目做大了以后真正的痛点往往不是模型能力而是提示词的管理。业务方提一个“你回答的时候能不能语气更友好一点”的需求你瞟一眼代码发现提示词嵌在一个200行函数的中部改了个寂寞。然后测试组反馈“你改了这句话之后线下分类器的准确率掉了好几个点”。你能怎么办提示词必须是独立管理、可版本化、可测试的资产。我在项目里把提示词全部集中到prompts/catalog.py中并且给每个提示词带上版本号和作用标签from dataclasses import dataclass from typing import Optional dataclass class PromptTemplate: name: str version: str description: str template: str variables: list[str] PROMPT_CATALOG { intent_router: PromptTemplate( nameintent_router, versionv1.2, description三分类意图识别 查询改写, templateINTENT_PROMPT, variables[user_query, history], ), rag_answer: PromptTemplate( namerag_answer, versionv2.0, description知识库问答综合回答, templateANSWER_PROMPT, variables[context, query], ), }每个提示词的变更都像代码变更一样走Code Review流程并且必须在commit message里注明prompt version变更。测试阶段我会专门跑一组“Golden Set”回归用例确保提示词改动的副作用被及时发现。7. 第五步测试、可观测性与部署——从能跑到稳定跑7.1 给大模型应用写测试的务实策略大模型应用测试让很多人头疼因为“输出本身不确定”传统单元测试的固定断言几乎不可用。我的策略是不追求输出内容的绝对一致而是追求输出结构的稳定和行为模式的符合预期。测试金字塔分三层第一层确定性单元测试覆盖配置加载、向量检索、提示词渲染、JSON解析等不涉及模型的纯逻辑。这一层跑起来毫秒级CI里全量跑是质量防线的基本盘。第二层半确定性集成测试会真实调用大模型API但断言的规则是“宽松的”。比如意图识别测试你断言的是“intent字段必须是chat/knowledge/general之一”confidence字段在0到1之间而不是断言“intent必须等于knowledge”。这类测试可以每天定时跑或者部署前手动跑。第三层Golden Set回归测试准备一组典型问题集比如GOLDEN_QUESTIONS [ {query: 什么是公司年假制度, expect_intent: knowledge, expect_has_source: True}, {query: 你好你是谁, expect_intent: chat, expect_has_source: False}, {query: 给你的代码写一个二分查找, expect_intent: code, expect_has_source: False}, ]每次提示词变更后都跑一遍对比行为是否发生了不该有的偏移。大模型应用测试还有一个关键技巧提前记录prompt和response的全文到测试报告里。这样即使断言只是“宽松判断”你也能人工从报告里看到异常输出长什么样进而定位根因。7.2 日志、追踪与Token成本观测AI应用的可观测性有一个普通Web服务没有的新维度Token成本。我经历过一个项目上线一周后发现账单高得吓人但没人能说清楚钱花在哪了。排查到后来才发现是某个测试进程在死循环调用模型。所以在最开始的Provider封装里我就把Token用量和时延作为标准字段返回。每次对话完成后把所有记录结构化写入本地日志并同步汇总到一个统计表中。结构化日志用JSON格式字段设计如下{ timestamp: 2025-05-06T14:22:31Z, event: llm_call, session_id: 会话ID, provider: openai, model: gpt-4o-mini, intent: knowledge, latency_ms: 842, prompt_tokens: 623, completion_tokens: 215, total_tokens: 838, cost_usd: 0.003, status: success }如果项目后续接入OpenTelemetry这些结构化日志也能很方便地转成trace数据。初期阶段没有上复杂链路的必要但日志格式从一开始就要按标准来否则后面改造成本会非常高。排查问题有一个非常经典的方式你发现今天80%的Token消耗都来自同一个session顺着session_id把日志拉出来就找到了异常行为。没有这个结构化的trace你面对的就是一片模糊的数据海洋。7.3 轻量化部署的实战流程项目部署我没有选择重型方案容器加进程管理足够。Dockerfile写起来非常简单但要留意构建缓存策略和依赖层分离的技巧否则每次构建都是痛苦的重装依赖过程。一个典型的多阶段DockerfileFROM python:3.11-slim AS builder WORKDIR /app RUN pip install uv COPY pyproject.toml uv.lock ./ RUN uv sync --no-dev FROM python:3.11-slim WORKDIR /app COPY --frombuilder /app/.venv /app/.venv COPY . . ENV PATH/app/.venv/bin:$PATH CMD [uvicorn, ai_eng.main:app, --host, 0.0.0.0, --port, 8000]生产环境一脸温和地说“你那模型是外部API”所以容器里不需要装庞大的模型权重镜像可以控制在几百MB级别部署非常轻。最后我提醒一个在很多项目中都被忽略的点CI不只是用来跑测试的也要跑到ruff check和mypy静态检查。AI项目代码里总是充满各种动态类型操作没有静态检查的代码库三个月后就没人敢改动了。8. 常见问题与排查技巧实录AI工程区别于传统后端的最大特点就是不稳定。这种不稳定不是靠多写几行防御代码就能解决的而是你需要一套足够完整的排查手段和心态。我把这半年踩过的坑整理成了一份“避坑速查表”每一条都是花了真金白银换回来的。现象可能原因排查思路与解决本地跑得好好的CI环境全挂依赖版本隐式浮动未锁lockfile切换uv sync构建环境确保uv.lock纳入版本库改了提示词线上效果突然变差改的提示词被缓存的旧结果命中大模型推理服务有prefix caching改动提示词后需清缓存/变更前缀Token账单月底暴增某个进程死循环调用模型或temperature调太高增加了生成长度拉取Token统计表定位异常session对llm调用加并发限流检索出来的文档跟问题完全无关向量化模型领域适配差或者切分太小丢失语义换用领域embedding模型调大chunk_size增加overlap模型总是回答“我不知道”提示词里Context格式差错模型没看到上下文打印实际拼装后的prompt检查变量是否真的被渲染进去了API调用的重试机制导致重复扣费OpenAI SDK默认自动重试区分可重试错误限流、超时和不可重试错误鉴权失败用指数退避策略对话里模型上下文越聊越乱历史记录无长度约束超出模型窗口后被截断做滑动窗口只保留最近N轮对话摘要历史代替原始历史测试时把开发环境的向量库数据污染了同一个vector_store实例被反复初始化配置中隔离dev/test/prod路径测试时用临时目录这些坑背后的模式其实就两类一类是“环境不一致”依赖、配置、向量库数据一类是“模型行为不可控”。环境不一致靠规范去解决模型不可控靠结构化约束和回归测试去兜底。9. 写在最后的几句实在话做完这个项目我最大的心得是AI工程的核心难点不在“AI”而在“工程”。模型的能力是飞速上涨的今天你花一个星期调试的效果半年后可能一个API参数就搞定了。但工程化的架构能力不会过时——如何解耦、如何观测、如何测试、如何控制成本这些能力在任何技术周期里都是稀缺的。“from scratch”这个过程确实会慢但它给你的回馈是你真正理解了自己系统里的每一个环节遇到问题你能快速定位、快速修复、快速迭代。那些用了全家桶框架跑起来很爽的项目一旦遇到框架不支持的功能你可能会花大把时间去读框架源码才能绕过那个成本远比你自己从零搭要高一两个量级。最后再分享一个小技巧。在项目的根目录放一个Makefile把最常用的命令都收敛进去——make dev启动服务make test跑全套测试make lint做代码检查make log看实时日志。这些小工具体验加在一起就是你在AI工程这条路上真正走得远的基础设施。项目永远不会是完美的但它可以是一直健康地长着的。
返回列表