
这几年做AI应用开发的人越来越多但有个现象我一直觉得特别值得琢磨很多项目在demo阶段惊艳全场演示完之后一上生产环境就原形毕露。有的是换了个模型就崩有的是prompt被用户输入稍微一干扰就答非所问有的是文档稍微多一点检索就乱成一锅粥更常见的是几个月后连作者自己都看不懂当初那串prompt是怎么拼出来的。我在自己的开源仓库里整理了一个名为ai-engineering-from-scratch的项目目标很朴素不依赖任何重量级AI框架从一个空的Python目录开始把AI应用从开发到上线涉及的工程环节一个个手工实现一遍搞清楚每一层背后到底发生了什么。项目覆盖了环境骨架、配置管理、Prompt版本化、模型接入抽象、RAG管道、Agent工具调用、自动化评测和部署成本优化这几个核心环节。这篇文章就是我对整个项目做法的系统梳理适合正在从调接口走向做产品的工程师也适合想完整了解AI工程全貌的入门者。1. 从Notebook能跑到项目能用这个AI工程仓库到底在补什么课先说清楚这个项目不解决什么问题它不教你大模型的基础原理不教你如何训练或微调模型它解决的问题全部落在工程两个字上。你在Notebook里写一个调用大模型API的函数很容易二十行代码就能跑通但当你开始认真做产品需要考虑的东西会一下子冒出来密钥放在哪里、不同环境怎么切换、Prompt改了一版会不会把其他功能带崩、用户问了一个不在知识库里的问题怎么兜底、模型连续返回五次错误要不要重试、这个月的API账单为什么翻了三倍。这些问题没有一个可以在Notebook里想清楚它们牵扯到的是软件开发里最经典的那些话题环境管理、配置管理、模块解耦、可测试性、可观测性、成本治理。只不过因为AI应用的逻辑链路比传统Web应用更长这些工程手段的组合方式也随之变得特殊。我建这个仓库就是为了把这条长链路上容易出问题的环节全部暴露出来然后用最直白的方式逐个解决。1.1 这个项目适合谁不适合谁如果你已经用LangChain或LlamaIndex写过不少AI应用但始终觉得自己像是在搭积木对每个环节内部到底发生了什么没有把握那这个项目很适合你因为它是从零手写的每一步都看得见摸得着。如果你是刚入门的开发者只会在网页上点一点对话机器人也想搞清楚从聊天到产品之间到底隔着什么同样可以从这套代码里找到答案。反过来如果你现在要做的是一个非常复杂的生产级系统时间紧任务重那我不建议你从零手写直接上成熟框架会是更理性的选择。这个项目的意义不在于推荐你不用框架而在于让你在用了框架之后仍然知道底层原理是什么。框架帮你省时间理解帮你省出事故后排查的时间两者并不冲突。1.2 我给自己定的技术栈和约束既然叫from scratch我给自己定的原则是尽量少依赖重量级框架。最终的依赖清单大致是这样的Python 3.11以上类型注解用起来顺滑OpenAI SDK但只作为传输层使用业务代码不直接和它耦合Pydantic做数据模型校验结构化输出的命根子向量存储先用内存索引生产环境可以平滑替换为pgvector或MilvusFastAPI用于最后把能力包装成HTTP服务其他就是一些常规工具库比如Jinja2做Prompt模板、Tenacity做重试全项目没有用LangChain原因很简单我想搞清楚每一层到底发生了什么。等这套从零实现的代码跑通了再回头去看LangChain的代码你会发现自己能轻松看懂每个组件为什么不那么设计也能更准确地判断什么时候该用它、什么时候不该用。2. 空目录开始搭骨架环境、配置与模块边界的一次性理顺很多AI项目死于前期基础设施太随意。我见过太多仓库依赖装得乱七八糟用户名密码直接写在代码里换一台电脑就再也跑不起来。既然是from scratch第一步就得把地基打好这一步省下来的时间会成倍回报在后续的每一个功能上。2.1 依赖管理用uv保持环境的干净与一致我用的依赖管理工具是uv它比传统的pip venv快一个数量级更重要的是它的锁文件机制能让团队所有人装出完全一致的依赖版本。初始化项目的命令很简单uv init ai-engineering-from-scratch cd ai-engineering-from-scratch uv add openai pydantic fastapi jinja2 tenacity python-dotenv uv add --dev pytest ruff mypy这里有一个新手特别容易忽略的点一定要区分运行依赖和开发依赖。pytest、ruff这些只会在本地开发和CI里用到如果混在运行依赖里一起部署不仅镜像体积变大还会带来潜在的安全风险。用uv add --dev单独隔离它们是成本最低的好习惯。2.2 配置管理的三个层次密钥、环境与默认值配置管理是我在这个项目里特别想强调的部分。AI应用的配置比传统应用多一个维度——你不仅要管数据库地址和端口还要管模型名称、温度参数、最大token数、Prompt版本号这些配置一旦混在一起很快就会失控。我采用的方式是分三层管理配置类型存放位置说明密钥类配置.env文件API Key等敏感信息绝不进Git环境差异配置config/dev.py、config/prod.py不同环境下模型、参数可能不同不可变默认值config/constants.py固定常量比如默认温度、超时时间读取配置统一用pydantic-settings所有配置项收敛到一个Settings对象里而不是到处散落os.getenv。from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config SettingsConfigDict(env_file.env, env_prefixAIENG_) openai_api_key: str default_model: str gpt-4o-mini temperature: float 0.3 max_tokens: int 2048 request_timeout: float 30.0 vector_db_path: str ./data/vector_store.json settings Settings()这样做的最大好处是项目里任何地方需要配置都从同一个settings对象里取绝不存在这个环境变量是哪来的这种疑问。密钥文件通过.gitignore排除仓库里只留一个.env.example模板队友克隆下来复制改名就能跑。2.3 目录结构与模块边界依赖方向必须单向目录结构看起来简单但边界设计才是关键。我把项目拆成了这样src/ai_engineering/ ├── api/ # HTTP接口层只做参数校验和结果返回 ├── application/ # 业务用例层编排底层能力 ├── llm/ # 模型接入层所有模型调用收敛到这里 ├── prompts/ # Prompt模板与版本管理 ├── rag/ # 检索增强生成相关组件 ├── agent/ # Agent循环与工具调用 ├── eval/ # 评测集与自动化评测 └── config/ # 配置管理模块边界有一条铁律依赖方向只能从外向里。api依赖applicationapplication可以依赖llm、rag、agent但反过来不行。这听起来像软件工程的常识但AI项目因为链路复杂经常出现图省事在api层直接调模型的情况一旦出现后续任何改动都会变成打地鼠。3. Prompt工程化提示词不是字符串是应用的第一行代码Prompt是AI应用里最容易被轻视的资产。很多人一开始就是把提示词写成一行字符串跑通了就再也不管。但过两个月回头改的时候连当初为什么写请你扮演一个资深的xxx专家都想不起来了更可怕的是你改了一个Prompt所有用它的功能全部悄悄变化线上出了事故都不知道源头在哪。3.1 硬编码字符串为什么是灾难我把Prompt视为应用代码的一部分但它比普通代码更特殊它的行为不可穷举一小处改动可能导致完全不同的输出。所以它至少需要具备三个特征——版本化、可组合、可测试。在这个项目里我用Jinja2做模板渲染每个Prompt模板都有明确的版本号、输入变量和预期输出格式。一个典型的模板文件长这样# file: src/ai_engineering/prompts/templates/translator_v2.jinja 你是资深的技术文档翻译专家擅长将英文技术内容翻译为地道的中文。 请遵循以下约束 - 保留专业术语的准确含义 - 代码片段和命令不得翻译 - 如果原文有明显的表述错误在译文中修正并添加注释 待翻译内容 {{ source_text }} 请直接输出译文不要添加任何解释。配合一个简单的加载器统一管理模板的版本与渲染from pathlib import Path from jinja2 import Template class PromptManager: def __init__(self, templates_dir: Path): self.templates_dir templates_dir self._cache: dict[str, Template] {} def render(self, template_name: str, **variables) - str: template_key f{template_name}.jinja if template_key not in self._cache: path self.templates_dir / template_key self._cache[template_key] Template(path.read_text()) return self._cache[template_key].render(**variables)版本号直接体现在文件名里比如translator_v1.jinja和translator_v2.jinja都会保留在仓库里。如果新版本效果不好随时可以在代码里回退到旧版本这比在Git历史里翻旧代码要贴心得多。3.2 结构化输出比请输出JSON靠谱一百倍的方案让大模型输出自然语言很容易但让大模型输出严格的JSON你最好别指望它天生听话。最稳妥的方案是利用模型供应商提供的JSON Mode或Function Calling能力把输出结构定义成Pydantic模型由框架负责解析和校验。from pydantic import BaseModel, Field class TranslationResult(BaseModel): translated_text: str Field(description翻译后的文本) glossary: list[dict[str, str]] Field(description术语对照表) def translate_with_validation(text: str) - TranslationResult: completion client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt_manager.render(translator_v2, source_texttext)}], response_format{type: json_object}, ) content completion.choices[0].message.content return TranslationResult.model_validate_json(content)这里的核心思想是让模型输出符合schema而不是输出一段看起来像JSON的文本。解析失败时我会捕获校验异常并做一次重试一般第二次就能得到合法结果。3.3 Prompt测试把不稳定性关进笼子里Prompt没有标准答案但必须有回归测试。我会为每个Prompt准备一组测试用例记录输入和期望的行为特征比如翻译任务中检查术语是否保留、代码片段是否未被翻译。这些测试跑在CI里一旦有人改了Prompt导致某个用例不通过就能第一时间暴露问题。4. 模型接入层抽象为什么我坚持不直接import供应商SDKAI项目最容易踩的一个坑就是把某家模型厂商的SDK调用散落在业务代码的各个角落。今天用的模型和三个月后用的模型很可能不是同一家甚至可能是自托管的开源模型。如果一开始不做好抽象层换模型的成本高到让你只能将就着用最初那家。4.1 一个接口接所有模型我在src/ai_engineering/llm里定义了一个极简接口from abc import ABC, abstractmethod from dataclasses import dataclass dataclass class LLMResponse: content: str model: str usage: dict class BaseLLM(ABC): abstractmethod def complete(self, messages: list[dict], temperature: float 0.3, max_tokens: int 2048) - LLMResponse: 发送对话消息返回完整响应 ... abstractmethod def stream(self, messages: list[dict], temperature: float 0.3, max_tokens: int 2048) - Iterable[str]: 流式返回增量内容用于打字机效果 ...所有上游业务代码只依赖这个抽象接口具体是OpenAI、Anthropic还是本地模型全部通过依赖注入确定。这意味着从OpenAI切换到其他兼容服务业务代码一行都不用改。4.2 重试、流式与超时这三件事别等到线上才处理模型API的稳定性用过的都懂。网络抖动、服务过载、限流各种状况层出不穷。如果没有重试机制一个用户请求可能直接失败。我推荐用tenacity实现指数退避重试from tenacity import retry, stop_after_attempt, wait_exponential retry(stopstop_after_attempt(3), waitwait_exponential(min1, max10)) def complete_with_retry(client, messages, **kwargs): return client.chat.completions.create(messagesmessages, **kwargs)这里有个小细节不是所有错误都值得重试。模型返回400错误说明请求本身有问题重试一万次也没用但429限流和500/503错误则值得重试。所以重试前我会先检查异常类型避免把无效请求反复提交。流式输出同样需要细心处理。SSE流在网络中断时客户端应该能够自动重连而不是傻等。我在流式实现里加了一个简单的幂等机制记录已经推送过的消息ID重连后跳过重复片段。4.3 模型路由小任务用便宜模型大任务用聪明模型模型成本差异是惊人的。在ai-engineering-from-scratch里我实现了一个非常基础的路由策略根据任务类型和复杂度把请求分发到不同模型上。任务类型默认模型温度说明普通问答gpt-4o-mini0.3性价比优先代码生成gpt-4o0.2需要更强的逻辑能力摘要提取gpt-4o-mini0.1摘要不希望太发散复杂推理claude-sonnet0.0结构化思维更强这不是什么玄学就是把让合适的模型做合适的事落成代码。这样做的直接收益是成本下降一半以上同时整体响应速度也快了很多。5. 手写RAG管道切分、向量化、检索、生成的每一步取舍RAG检索增强生成已经是AI应用最主流的落地形态了但很多人对它的理解停留在把文档丢进向量库然后查出来塞给大模型。真正的工程难点藏在每个具体步骤的取舍里这些取舍才是决定回答质量的关键。5.1 文本切分边界比大小更重要文本切分是RAG里最不起眼却又最致命的一环。很多教程喜欢直接按固定长度切比如每512个字符一刀切。这种做法的后果是一个完整段落被拦腰截断检索时只召回一半内容答案自然拼凑不清。我在项目里实现了三种切分策略的对比固定长度切分实现简单但边界质量差按段落切分以换行为边界适合结构清晰的文档语义切分通过嵌入向量的相似度变化找到语义边界效果最好但计算成本高实际工程里我通常先用段落粗切再对过长的段落做二级切分同时保留段落上下文的元信息。每个切块会存一个切片ID还记录它在原始文档中的起始位置这样回答问题时可以精确回指到原文位置。5.2 向量化与索引小规模先别上重型武器向量化就是把文本变成数字数组这步最需要注意的是选择什么样的Embedding模型。不同模型的维度、语义表达能力、语言支持各不相同。在中文场景下用通用Embedding模型效果往往一般我建议优先考虑针对中文优化过的模型。索引部分我开发时只用了一个简单的内存索引import numpy as np class SimpleVectorIndex: def __init__(self, model, documents: list[dict], chunk_size: int 200): self.model model self.chunks [] for doc in documents: chunks split_text(doc[text], chunk_size) for i, chunk in enumerate(chunks): self.chunks.append({ doc_id: doc[id], chunk_id: i, text: chunk, metadata: doc.get(metadata, {}), source: doc.get(source, ), }) def search(self, query: str, top_k: int 5) - list[dict]: query_embedding self.model.embed(query) scored [] for chunk in self.chunks: chunk_embedding self.model.embed(chunk[text]) # 实际项目要预计算 score cosine_similarity(query_embedding, chunk_embedding) scored.append((score, chunk)) scored.sort(keylambda x: x[0], reverseTrue) return [chunk for _, chunk in scored[:top_k]]真实项目里预计算所有切块的嵌入并缓存搜索时只需要对query做一次嵌入然后做矩阵乘法就行。千万不要在查询时对每个切块都重新调用Embedding模型那样延迟会高到完全没法用。5.3 混合检索与重排序向量检索并不万能纯向量检索的痛点是它只认语义相似不认关键词。你搜API限流如何处理向量库里恰好有一段话是接口调用频次控制语义上相关但表达差异很大召回效果可能不佳。所以我在项目里加入了BM25关键词检索和向量检索的结果做融合再排序。def hybrid_search(query: str, top_k: int 5): vector_results vector_index.search(query, top_ktop_k * 2) keyword_results bm25_index.search(query, top_ktop_k * 2) # 使用RRFReciprocal Rank Fusion融合两个结果集 fused rrf_merge([vector_results, keyword_results], k60) return fused[:top_k]RRF算法的思路是把两个结果集的排名转化为分数然后求和排序。它不需要两个检索器的分数在同一量纲上实现简单、效果稳定是我在所有融合方案里最推荐的一种。5.4 生成与引用溯源给回答装上证据链RAG的最后一公里是生成回答并附上引用。很多实现直接把检索到的上下文全塞进Prompt不做筛选也不做标注导致大模型自由发挥产出幻觉。我的做法是把每个检索结果编号在Prompt里要求模型引用编号生成后再做一次解析把引用编号映射为原始文档的链接。请基于以下资料回答问题。引用资料时使用[1]、[2]这样的编号。 资料 [1] {{ chunk_1 }} [2] {{ chunk_2 }} 问题{{ question }} 要求如果资料中找不到答案直接说明资料不足不要编造。这一步让我在真实使用中避开了大量幻觉问题。用户问一个知识库外的问题时模型会老老实实告诉你资料不足而不是一本正经地胡说八道。6. Agent化的正确姿势从问答机器到会调用工具的执行者Agent是当前AI应用最热的方向之一但很多人做的Agent其实只是把大模型接到一个循环里缺少工程约束跑起来的成本和风险都很高。这一章我分享一下在这个项目里是怎么从零实现一个可控的Agent框架的。6.1 工具注册装饰器模式是最优雅的写法工具定义的核心是给模型一个清晰、严格的函数说明。我在项目里用装饰器模式把所有工具集中注册class ToolRegistry: def __init__(self): self.tools: dict[str, dict] {} def register(self, name: str, description: str, schema: dict): def decorator(func): self.tools[name] {name: name, description: description, parameters: schema, func: func} return func return decorator tool_registry ToolRegistry() tool_registry.register( namesearch_knowledge_base, description在知识库中检索相关资料参数query为检索关键词, schema{ type: object, properties: {query: {type: string}}, required: [query], }, ) def search_knowledge_base(query: str) - str: # 调用上一章的RAG管道 return str(rag_pipeline.hybrid_search(query, top_k3))这里有一个关键点工具的description和参数schema直接决定模型会不会正确调用它。写得含糊其辞模型就会自由发挥写得精确清晰模型几乎不会出错。我在实际测试中发现把参数约束条件写进description比如注意query应为中文关键词不超过10个字调用成功率能提升十个百分点以上。6.2 循环执行框架必须有硬性终止条件Agent的核心是一个循环模型看到用户请求后决定是直接回答还是调用某个工具如果是调用工具就把工具结果返回给模型让它继续推理。这个循环看似简单但你必须加上两个护栏——最大迭代轮数和单步超时时间。def run_agent(user_input: str, max_steps: int 5): messages [{role: user, content: user_input}] for step in range(max_steps): response llm.complete_with_tools(messages, toolstool_registry.tool_definitions()) if response.tool_calls: tool_name response.tool_calls[0].name tool_args json.loads(response.tool_calls[0].arguments) result tool_registry.execute(tool_name, **tool_args) messages.append({role: tool, tool_call_id: response.tool_calls[0].id, content: result}) continue return response.content raise AgentMaxStepsExceededError(max_steps)没有终止条件的Agent就是一个吞金兽模型可能会在工具调用里反复打转每次循环都在消耗token。我见过一个真实的例子一个Agent任务最终跑了32轮工具调用生成了近十万token结果只是做了一次简单查询。从此我坚定了一点任何Agent都必须有硬上限。6.3 多Agent协作的简单实现规划者与执行者再说一说多Agent。我用一个最经典的组合来演示——规划者负责拆解任务执行者负责具体干活。两个Agent通过消息队列传递信息规划者只分析不直接接触工具执行者只执行不做全局决策。class PlannerExecutorArchitecture: def __init__(self): self.planner create_agent(planner) # 只会拆解任务 self.executor create_agent(executor) # 只会调用工具执行 async def run(self, task: str) - str: plan self.planner.complete(f请将以下任务拆解为可执行的步骤{task}) steps parse_plan(plan) results [await self.executor.complete(step) for step in steps] return synthesize_results(results)这种架构的价值在于一方面可以给不同Agent配置不同模型规划者用推理强的模型执行者用便宜快速的模型成本上更优另一方面职责清晰出问题时容易定位。如果你的需求比较简单不必为了多Agent而上多Agent拆分子任务本身也是有成本和误差的。7. 评测与部署才是真正的试金石质量护栏与成本账本很多AI项目做完开发就以为结束了实际上一个没有任何自动化评测的AI应用跟一辆没有刹车就上路的车没有区别。你每次改Prompt、换模型、调参数都等于在没有仪表盘的情况下驾驶。7.1 评测集怎么建三五十条用例起步分场景覆盖项目里我建立了一个eval目录整理了一份包含基础场景的评测集。按下面的维度搭建基本能覆盖日常开发中90%的回归需求知识库内问题有标准答案用于检验检索与生成的准确率知识库外问题没有答案用于检验模型是否会诚实回答不知道多跳问题需要拼接多个文档才能回答用于检验RAG的上下文能力工具调用型任务用于检验Agent的工具选择与参数提取边界输入超长文本、空字符串、恶意字符等用于检验健壮性评测集不需要一上来就做一千条三五十条高质量的用例就够了重点是持续补充和更新每当线上出现一个答得不好的案例立刻把它收进评测集防止它后面再次发生。7.2 客观指标与LLM打分结合不要让模型既当运动员又当裁判评测指标的选择同样要讲究。回答的准确性适合用LLM打分但最好是让一个独立模型来评而不是跑任务的那个模型。忠实度指标用来检查回答是否严格基于检索资料这比准确性更适合自动化验证因为它可以检测回答中是否有内容超出上下文。指标衡量内容方式召回命中率检索是否找到正确答案所在文档程序自动判断忠实度回答是否严格基于检索资料独立LLM打分引用覆盖率回答的关键句子是否有引用支撑程序LLM结合端到端准确率最终答案是否正确独立LLM打分每次修改代码后跑一遍评测集所有指标与上一次对比。这相当于给AI应用上了一道CI护栏让改了一行代码导致全网崩溃的事情在合并到主分支之前就被拦截。7.3 部署要点与成本优化上线只是开始部署阶段我把它包成一个FastAPI应用用uvicorn启动前面挂一层Gunicorn做进程管理。真正想提醒大家的是三个部署层面很容易忽略的细节第一缓存重复请求。用户的问题往往是高度重复的比如怎么重置密码。我在API层做了一层语义缓存将用户问题的嵌入与历史请求匹配如果相似度超过阈值直接返回缓存结果。这一步能让整站成本下降30%以上响应时间也能从秒级降到毫秒级。第二流式输出的超时控制。大模型响应慢起来能让人怀疑人生请求必须设超时且流式响应要支持客户端随时断开。我在网关层实现了请求ID追踪任何一个请求从进入到返回的全链路耗时都能查到排查慢请求时非常有用。第三成本观测要细化到每一个调用。我在每次模型调用的响应里提取usage信息把token数、模型名、任务类型写入结构化日志按天聚合。最终会给出一张这样的成本账本模型调用次数输入Token输出Token估算费用gpt-4o-mini182024.6M3.1M$5.2gpt-4o2608.9M1.2M$18.5看到账单之后你才会真正理解模型路由和缓存的必要性。没有这个账本成本失控是悄无声息的。8. 写在最后这套代码陪我趟过不少坑这里有一些额外的心得项目做到后期我最大的感受是AI工程的根本难点不在于某个算法有多炫而在于系统的稳定性和可维护性。模型能力在快速迭代但工程的那些老规矩——模块解耦、依赖管理、自动化测试、可观测性——永远不会过时。它们只是换了表现形式继续在AI应用里发挥着同样的作用。如果你也想从零搭一个类似的仓库我的建议是不要贪多按顺序一步一步来先跑通一个最小的对话应用然后加RAG再加Agent最后补上评测和部署。每加一层之前先想清楚这一层要解决什么问题、出了故障怎么排查然后再动手。代码写到后来你会发现你收获的不仅仅是一个AI应用而是一整套能让你睡得着觉的工程体系。最后分享一个小的实操技巧不要一开始就追求完美的抽象和框架先把一个最土但能跑通的端到端链路打通然后再一块一块替换成工程化的方案。这个项目名字叫from scratch但它并不是让你一步到位写出完美代码而是让你每一步都清楚自己在写什么、为什这么写这条路走一遍之后你再看任何AI框架的源码都会觉得亲切很多。