“你这项目其实不难,但你是不是还没跑通一个闭环?”这是上周一个做技术的朋友看完我 RAG 项目之后说的第一句话。当时我有点不服气,但他问得没错。市面上关于 ai-engineering from-scratch 的教程很多,可大多数都卡在两个极端:要么上来就甩一堆数学符号,要么就是“调一个 API 就完事”的 demo。真正从零开始动手的人,最需要的是知道先做什么、后做什么、每一步为什么要这么做。我花了一年时间从连 embedding 是什么都不知道,到做出稳定回答内部文档问题的应用,再到接上 Agent 工具链和完整评测体系。这篇内容就是我验证过的最短路径,不绕弯子,把每一个坑和对应的解决办法都摊开讲。适合没有 ML 背景但会写代码的工程师,也适合已经会调 API 但总觉得系统不稳定的朋友。
1. 先诊断:你要做的到底是“AI 工程”里的哪一块
很多人学 Deep Learning 学得头昏脑涨,原因不是不努力,而是没搞明白自己根本不需要那部分知识。在做任何学习计划之前,先把自己想要的结果归类,能帮你省下大量时间。AI 工程这个盖子下面,大致有三条完全不同的路。
1.1 三条路线的目标、成本与受众完全不同
这里我先说结论:大部分从零开始的人,应该走路线 A,而不是冲进路线 C。
| 路线 | 目标产物 | 核心知识 | 首个可见成果 | 适合谁 |
|---|---|---|---|---|
| A. AI 应用开发 | RAG 问答、Agent 工作流、API 产品 | API 调用、提示词、检索系统、后端工程 | 2-4 周做通一个 RAG | 大部分想入行的工程师和产品经理 |
| B. 模型工程 | 私有化模型、推理优化、微调 | PyTorch 基础、量化、推理框架、训练/lora | 1-3 个月跑通一次微调 | 有数据处理或 ML 基础的人 |
| C. 算法研究 | 新模型、新机制 | 深度学习理论、扎实数学功底 | 半年到一年 | 目标进研究机构或实验室的人 |
我遇到过太多想做“AI 产品”的人,第一件事却是去啃论文,这是最典型的路线错配。反过来,如果你目标是做模型优化,天天调 API 写提示词也不行。路线本身没有高低,只有是否匹配你的目标。
1.2 从零开始的技能基线:会这些就够起步了
路线 A 的真正门槛,不比你之前做过的任何一个 Web 项目高。你只需要:
- 会读 Python 代码,会写简单的函数和类
- 熟悉命令行操作和 JSON 数据结构
- 知道怎么调用 HTTP API(requests 都可以)
- 会查文档、会搜索引擎、会调试
仅此而已。线性代数、反向传播、Transformer 架构这些,起步阶段完全用不上。我见过一个前端同事,几乎不懂 Python,用了两周就把一个文档问答应用跑起来了。他的核心技能是“会调试”,这比数学能力重要得多。
我的观点很直接:AI 工程师首先是工程师,其次才是 AI。很多人被“AI”两个字吓住了,反而忽略了自己已有的工程能力。这种能力迁移,才是 from-scratch 最常用的起跑器。
1.3 我验证过的 90 天起步顺序
如果你认同上面的诊断,接下来可以直接照抄这个节奏:
- 第 1 周:把本地环境搭好,用最简代码调用一次大模型 API,把输入输出跑通
- 第 2-4 周:不碰任何复杂框架,纯手写一个 RAG 管线,解决一个具体业务问题
- 第 5-8 周:在 RAG 基础上扩展 Agent 工具调用,让系统能完成“检索+动作”
- 第 9-12 周:补上评测集、日志监控、成本控制,让项目可以长期维护
这个顺序的核心逻辑是:每个阶段都在为下一阶段提供可复用的骨架,而且每个阶段都有一个可量化的里程碑。没有里程碑的阶段,很容易变成无底洞。
2. 搭环境的时候,真正让你慢下来的不是代码
第一条建议很简单:别用系统自带的 Python。系统 Python 往往版本老旧,还和系统其他软件共用环境。我曾经在 macOS 上直接用系统 python,装一个科学计算库的时候,把系统关键路径搅得一塌糊涂,重装系统才算完。
2.1 先用 pyenv 把 Python 版本锁死
我认为 Python 版本选择本身比文本编辑器、IDE 之争重要得多。具体操作是装 pyenv,然后固定项目要用的版本:
pyenv install 3.11.x pyenv local 3.11.x不要追求最新版本。3.13 虽然新,但很多 AI 库的二进制轮子还没跟上,遇到莫名其妙的编译错误很浪费时间;3.11 和 3.12 是当前生态验证最充分的版本。版本统一的好处是,你踩过的坑别人大概率也踩过,搜解决方案最容易命中。
2.2 用 uv 而不是 pip 或 conda
包管理这一层,我用 uv 之后几乎没再碰过 pip 和 poetry。uv 用 Rust 写的,安装依赖的速度比传统方案快一个量级,而且生成的锁文件非常干净,遇到版本冲突时提示也清晰。
uv init ai-engineering-from-scratch uv add openai chromadb sentence-transformers另外,我强烈建议:初期不要装 langchain 这类重型框架。不是说它不好,而是它把太多细节藏起来了。从零开始的最大优势就是能看清每一步的内部逻辑。等你自己手写过一遍 RAG 和 Agent,再回来用框架,感受会完全不同。
2.3 本地模型和 API:开发期怎么选
这里我给一个我踩过很多坑后确定的策略:开发调试用本地模型,效果验证用 API。
| 选型 | 优点 | 缺点 | 适用阶段 |
|---|---|---|---|
| 本地模型(Ollama) | 免费、隐私、可离线、不限请求量 | 效果弱于顶级 API,需要显卡配置 | 开发调试、跑通链路、单元测试 |
| 模型 API | 效果直接、省事、无需维护硬件 | 按 token 收费、依赖网络 | 最终产品、关键效果验证 |
本地模型推荐 Ollama,两条命令就能跑起来:
ollama pull qwen2.5:7b ollama pull deepseek-r1:7b ollama run qwen2.5:7b我实际的体验是:本地 7B 模型在多数简单问答上已经能干活,但一旦涉及复杂推理和多步工具调用,明显不如 API 大模型,所以两者是配合关系,不是替代关系。
2.4 一个够用且不臃肿的目录骨架
项目结构不用复杂,但必须从一开始就分清楚职责。我目前最常用的是这个骨架:
aideep/ ├── app/ # API 服务和入口 ├── data/ # 原始文档和切分结果 ├── src/ │ ├── loader.py # 加载不同格式的文件 │ ├── chunker.py # 文本切分逻辑 │ ├── embedder.py # 向量化封装 │ ├── retriever.py # 检索逻辑 │ └── generator.py # 生成逻辑 ├── tests/ ├── eval/ # 评估集和打分脚本 └── pyproject.toml不要一开始就搞微服务、消息队列、K8s。AI 工程和传统后端最大的不同,是核心链路本身还包含不确定性,你需要先让链路稳定,再谈架构。过早引入基础设施,只会让你分不清问题出在代码逻辑还是出在编排层。
3. 第一版 RAG:把“检索+生成”完整做通
我默认你已经有具体的业务场景,比如给团队做一个内部产品文档问答。没有具体场景的 RAG 学习项目,很容易迷失在参数调整里。为什么不建议做“通用问答”?因为切分策略、检索参数全都依赖资料类型。你问的是操作手册还是论文库,处理方式完全不同。
3.1 切分是 RAG 效果的第一道坎
这一步看着不起眼,实际决定检索质量的上限。我最早期版本直接按固定 512 个字符硬切,结果一个二级标题被劈成两半,检索出来的内容前言不搭后语。后来改成按 Markdown 标题结构切分,效果立刻上来。
def split_by_headings(markdown_text, max_chars=800, overlap=120): sections = [] current = "" for line in markdown_text.splitlines(): if line.startswith("#") and current: sections.append(current) current = line + "\n" else: current += line + "\n" if len(current) >= max_chars: sections.append(current) current = current[-overlap:] if current: sections.append(current) return sections这段代码不完美,但它表达了核心思想:优先按文档结构切分,长段落再按长度二次切,且保留重叠区域。至于参数,我给一组我实测的参考值:
| 资料类型 | chunk_size 建议 | overlap 建议 |
|---|---|---|
| 短说明文档 | 400-600 字符 | 10% |
| 长教程/章节 | 600-1000 字符 | 15-20% |
| 表格/代码为主 | 300-400 字符 | 10% |
提示:切分时不要把 Markdown 的代码块拦腰截断,否则检索到的那段内容在生成阶段会有很差的阅读体验。可以在切分前先识别代码块边界,把它们保护起来。
3.2 向量化:选一个稳的中文 embedding
中文场景下,我推荐 BGE 系列,bge-m3 在同尺寸模型里表现很稳,既能做稠密检索也能做稀疏检索。你也可以直接用模型 API 提供的 embedding 接口,省一点维护成本。
通过 sentence-transformers 加载本地模型的用法:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-m3") chunk_vectors = model.encode(chunks, normalize_embeddings=True)嵌入模型选好后,尽量不要频繁更换,因为每次更换都要重新向量化整个知识库。这也是为什么建议先认真选型、再动手索引。
3.3 向量数据库:起步用 Chroma 足够
很多人在向量库选型上纠结太久,实际上数据量不超过几十万条、并发不高的时候,Chroma 完全够用。它部署在应用进程里,API 简单,对从零开始非常友好。
import chromadb client = chromadb.PersistentClient(path="./data/chroma") collection = client.get_or_create_collection("docs") collection.add(ids=[str(i) for i in range(len(chunk_texts))], documents=chunk_texts, embeddings=chunk_vectors) results = collection.query(query_embeddings=[query_vec], n_results=5)等数据量真的涨上去了,再迁移到 Qdrant 或 Milvus 不迟。迁移的成本比你想象的低,核心 API 概念是相通的。
3.4 检索环节:top-k 和 rerank 的正确用法
默认 top_k 不要太大,4-8 是比较稳的区间。我试过 10 以上,效果反而变差,因为引入了太多不相关内容,冲淡了生成阶段的注意力。如果直接命中的结果不够准,加一个重排序环节,效果提升非常明显。
做法很简单:先用轻量向量检索召回 20-30 个候选,再用 rerank 模型精排,取前 3-5 个。推荐用 BGE-reranker,它只负责打分排序,不需要向量化。
注意:rerank 的代价是延迟增加几十到几百毫秒。内部知识库问答这种场景完全值得;但如果你的场景对延迟极敏感,就要权衡收益率。
3.5 生成环节:把上下文拼成一段可回答的提示词
prompt 模板我固定了以下结构:
你是内部文档问答助手。只能根据下面的参考资料回答,回答时在句末用 [来源编号] 标注引用。 如果资料中没有答案,直接说“资料中没有相关信息”,不要编造。 参考资料: [1] ... [2] ... 问题:...temperature 设置成 0.2-0.3。为什么?回答稳定性优先。做知识问答不是创作文案,你不需要模型发挥天马行空,需要的是每一句都有据可查,所以让输出的确定性尽量高。
3.6 第一版跑通后,立刻做三件事
这一步很多人会跳过,但它是后续优化的地基:
- 随机抽 10-20 个真实问题,把检索结果打印出来人工看
- 把采样结果记录在 eval/ 目录里,形成第一版 baseline
- 在调用链路上加上最基础的日志(问题和答案原文)
我见过最快翻车的方式,就是跑通 demo 后立刻去调 prompt,却没有记录前后对照。没有 baseline,你根本分不清改进是真的还是自我感觉良好。
4. 从 RAG 到 Agent:工具、循环与状态管理
RAG 的需求是“基于资料回答问题”,但用户经常要的不只是回答。比如“这个季度三个项目的预算总和还剩多少?”,RAG 可以检索到“项目 A 剩余 4 万,项目 B 剩余 2 万,项目 C 剩余 6 万”,但“求和”这一步必须交给工具。这就是 Agent 的价值:把大模型从“只会回答”变成“能调工具、能计算、能执行动作”。
4.1 最小 Agent 循环:四个要素缺一不可
不要被 Agent 这个词吓住。核心循环就四步:
- 模型根据用户问题决定是否调用工具、调用哪个工具
- 解析模型的调用请求,拿到工具名和参数
- 执行工具,得到结果
- 把结果作为新消息回填给模型,模型判断是继续调用还是最终回答
一个能跑通的最小循环代码大概是这样:
def run_agent(question, tools, max_steps=5): messages = [{"role": "user", "content": question}] for step in range(max_steps): resp = llm.chat(messages, tools=tools) messages.append(resp) if resp.get("tool_calls"): for call in resp["tool_calls"]: result = execute_tool(call) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": json.dumps(result, ensure_ascii=False) }) else: return resp["content"] return "reach max steps"这个循环虽然简单,但已经是很多成熟 Agent 框架的原型。你完全可以在没有框架的情况下先跑通它,再考虑引入抽象层。
4.2 工具注册与描述工程:模型远比你想象的“傻”
Agent 能否选对工具,极大程度取决于工具描述写得好不好。我吃过一个亏,工具描述写得太简单,模型在多个相似工具面前犹豫不决,反复追问用户“您是想查天气还是想查降雨量”。问题不在模型,在描述。
差的描述:“获取天气”。
好的描述:“根据城市名获取未来 3 天天气。入参:city_name,中文城市名,例如‘北京’;返回 JSON 包含每日最高/最低温度和天气现象,用于出行建议、穿衣推荐。”
差别在哪?好的描述写清了入参格式、返回结构和使用场景。模型没有常识,你要手把手告诉它这个工具是干什么的、怎么传参、拿到结果后能做什么。写工具描述有个技巧:把它当成“写给新同事看的接口文档”,而不是“写给自己看的注释”。
4.3 JSON 解析与防跑飞
模型偶尔会输出不合法的 JSON,这种情况在本地小模型上更常见。我的处理策略是:不要直接 json.loads,先截取第一个{到最后一个}之间的片段再解析,解析失败就让模型重试一次。
另外,max_steps 必须有。没有上限的 Agent 循环,早晚会陷入“模型问自己-自己回答”的死循环,既浪费 token 又让用户干等。生产环境我通常把上限设为 3-5 步,超过就直接返回当前信息和“暂时无法完成该操作”的兜底提示。
4.4 多轮状态管理
把 Agent 的 messages 列表作为 session 状态持久化,这样多轮对话里模型能记住上下文。但 messages 不能无限增长,超出上下文窗口就会报错或丢信息。我的经验是:最近两轮完整消息保留,更早的历史用一句话总结概览,再塞回上下文中。这个策略在成本和效果之间比较均衡。
5. 评测和监控:不做,你永远不知道改得好不好
这是我吃过最大的亏。有一阵子我改了 prompt,觉得回答质量“好像变好了”,就把新版本推到线上。结果一周后用户反馈说答案质量下降,我拿不出任何数据反驳,只能灰溜溜回滚。根源就是没有评估集。从那以后,所有改动进线上之前,必须过一遍评估。
5.1 没有评估集就是盲调
评估集不需要一开始就很大,但必须是真实的问题。你可以在三个渠道收集:
- 真实用户对话记录(最有价值)
- 内部文档中重点内容转写成的提问
- 业务同事常问的问题
我的建议是 30-50 条起步,覆盖不同难度。不要追求一开始就做 500 条,初期 50 条足够让你在“大版本方向”上做判断。标注也不要求写标准答案,只要标注回答中必须覆盖的关键信息点就行,打分时重点看有没有覆盖到。
5.2 离线指标:检索和生成必须分开看
很多团队只盯着最终答案看,这样的坏处是:生成模型很强的时候,检索错了它也能强行圆回来,但细节处全是错的。所以我把指标拆成检索侧和生成侧。
| 环节 | 指标 | 判断标准 |
|---|---|---|
| 检索 | 命中率 | 正确的资料是否出现在前 k 个结果里 |
| 检索 | recall@k | 关键词点是否被召回 |
| 检索 | MRR | 正确结果排得有多靠前 |
| 生成 | 忠实度 | 回答是否基于资料,而非凭空编造 |
| 生成 | 相关性 | 是否直接正面回答用户问题 |
| 生成 | 完整性 | 答案是否覆盖了所有关键信息点 |
不一定要算得特别严谨。手动抽 20 条,逐条给一个 0-5 分,计算平均分,也比“我感觉变好了”靠谱一万倍。
5.3 用 LLM 当裁判的三个原则
我自己跑过几轮 LLM-as-judge,也踩过一些坑,总结出三条原则:
第一,打分模型不要和被评估模型同源。比如你的主模型用的是 Qwen,打分就尽量别用同一个,否则会有一致性的偏好在里面。
第二,一次只评估一个维度。你让模型同时打“忠实度、相关性、完整性”,结果它往往只关注最明显的那一个维度,另外两个随机给分。分开跑效果会稳定很多。
第三,自动打分之外一定要有人工抽检。评估 prompt 可以参考:
请对回答质量打分,0-5 分,只评估忠实度: 即回答是否严格基于参考资料,而非编造内容。 参考资料:{...} 回答:{...} 只输出 JSON:{"score": 0, "reason": "..."}5.4 线上监控:最简但必须有的摸具
日志至少要记录这几个字段:用户问题、检索到的资料 ID、最终答案、模型延迟、用了多少 token。这几个字段能让你在用户说“回答很怪”时,几分钟内定位是检索问题还是生成问题,还是输入上下文被截断导致丢信息。
另外建议每天看一眼“平均首 token 延迟”和一个“每请求 token 数”。前者影响体验,后者直接决定成本曲线。用户对 AI 系统的容忍度远低于对普通网页的容忍度,3 秒没反应就会认为“AI 挂了”。
6. 走向生产:成本、延迟、幻觉三个绕不开的话题
当你的 RAG/Agent 不再是玩具之后,来自生产环境的压力会接踵而至。我挑出三个最现实的话题,每个都是我用钱和头发换来的经验。
6.1 token 经济账:先学会估算每一次请求
很多人对 token 成本没有概念。我来算一笔典型账:假设一次问答中,检索资料占 800 字、历史对话占 1500 字、提示词占 200 字,输出答案约 500 字。中文场景下一个汉字大约对应 1-2 个 token,所以一次请求总消耗大约在 3000-6000 token 之间。
如果每天有 1000 个用户请求,日消耗就在 300 万-600 万 token 之间。不同模型 API 的单价差异很大,但换算之后的结论是一致的:上下文无节制往上堆的时代已经结束,每一轮对话都要思考一个问题——“这段历史信息真的需要保留吗?”
6.2 上下文裁剪的三种手段
裁剪不是简单粗暴地截断,推荐按优先级组合使用:
- 滑动窗口:只保留最近两轮完整消息,最老的消息直接丢弃
- 历史摘要:把更早的对话压缩成一句概括,既保住了“用户大概聊了什么”,又能控制长度
- 关键信息抽取:对用户画像类信息(比如用户之前提到的偏好),单独存到结构化字段里,而不是留在对话历史中随窗口滑掉
我只用第一个手段的时候,用户连续聊了十轮之后,系统已经忘了第一轮提到的关键信息。加上历史摘要之后,这个问题基本解决,成本也只增加了一小部分。
6.3 幻觉与引用约束:把问题暴露出来,而不是假完美
幻觉无法彻底根除,但可以工程化约束。最简单有效的一招就是:prompt 强制要求回答者只能使用给定资料,且必须带引用编号。这样,即使模型某次编造了一个不存在的“资料”,用户也能看到引用的编号是伪造的,这件事就变得可验证了。
在高风险场景(财务、医疗、合同),不要只靠提示词,再加上结构化输出+规则校验。比如让模型先输出 JSON 格式的回答,再写一层规则检查:凡是涉及数字的语句,必须与检索到的原文逐字对照。这个规则写起来不复杂,但它能把错误扼杀在出口。
6.4 流式输出、语义缓存与可替换性
流式输出是延迟优化的第一选择。把“等待完整答案”变成“边生成边看”,用户感知上的首字延迟会从 1-3 秒降到 300ms 左右,体验提升非常明显。
语义缓存适合重复度高的场景。用户问“你们的退款政策是什么”,每天被问上百遍,没必要每次都调模型。把问题和答案向量化存储,新问题先算相似度,命中就直接返回缓存答案。注意相似度阈值要保守,低于 0.92 就别用了,宁可不命中也不给错答案。
最后一件必须现在做的事:把模型调用封装成一层独立接口。不要在你的业务代码里到处直接调用某个专属 API。理由很简单,大模型市场半年一小变,一年一大变,你要能随时切换到效果更好、成本更低的新模型。切换之后重跑一遍评估集,用数据说话,而不是靠某天刷到热搜就急着换。
把模型调用封成 interface 并不难,难的是持续用评估集验证替换后的效果。我每季度会跑一轮“模型对比评测”,拿同一批 50 条评估集,分别用不同模型跑,得到一个对照表。这个表就是模型选型决策的依据,比任何技术博客的推荐都可靠。
写到这里,我想把最真切的一点体会放在最后:从零开始做 AI 工程,最大的敌人不是复杂算法,而是迟迟做不出一个能跑的闭环时,那种反复怀疑自己的感觉。我第一版 RAG 花了整整一周,跑通验证集的那个晚上,比后来研究任何论文都踏实。别怕代码简单,也别迷信架构先进。先把最小闭环稳稳跑起来,再把评测、监控、成本控制一层层加进去。
最后分享一个坚持到现在的习惯:把每次改动的评估分数和踩坑记录写在项目根目录的 CHANGELOG.md 里,格式随意,哪怕一句话都行。三个月后回看,你会看到一条非常明显的能力提升曲线——那才是从零开始这件事最大的回报。