做AI工程这件事,我真正上手到现在快三年了。看到“ai-engineering-from-scratch”这个项目标题,我第一反应就是共鸣:它不是在讲某个模型多聪明,而是在讲一条路——从一个什么都不懂的状态出发,怎么一步步把AI能力做成真正能上线、能维护、能评估的工程系统。这类内容在技术社区里通常以仓库、课程笔记或者内部训练手册的形式出现,如果你正准备入行,或者已经在用AI但总觉得“离工程还差点意思”,这份笔记就是照着这条路走下来的完整过程。
1. AI工程的第一步,是重新定义问题
很多人问我AI工程和“玩模型”到底有什么区别。我说差别不在工具,而在目标。玩模型的目标是“让它跑起来”,出个结果发朋友圈就完事了。工程的目标是“在限定的资源、限定的时间、可维护、可评估的前提下,稳定交付一个能解决业务问题的系统”。同一个项目,前者一天能出Demo,后者要先写设计、拆任务、定指标,然后才开始写代码。这个过程就是“ai-engineering-from-scratch”和普通调用AI接口之间最本质的差异。
1.1 先分清算法思维和工程思维
我见过不少从算法转过来的朋友,习惯了“我把loss降下来就算完成任务”,但工程视角完全不同。举个例子,你拿到一个需求:“帮我看一下合同”,这句话在工程里根本没法执行。你要拆成:
- 输入是什么?扫描件还是Word?扫描件要不要OCR?
- 输出是什么?一段摘要还是结构化的JSON字段?
- 错了会怎样?抽取错了是人工复核,还是后续流程直接依赖这个结果?
这三个问题答案不同,方案就完全不同。如果只是摘要,调一个商用API可能五秒钟搞定;如果要结构化抽取关键条款且错误会直接影响财务流程,那就要做数据标注、效果评测、兜底规则、审计日志,甚至要接人工复核节点。
用做菜类比:算法思维是研究火候,工程思维是开一家能天天出餐、客人等太久会投诉、食材用完能补货的餐厅。前者是科学,后者是系统工程。
1.2 先画能力边界,再谈模型选型
我在项目里养成了一个习惯,接到需求先不碰代码,画一张“能力边界图”。横轴是输入类型,纵轴是输出要求,再用几个词标出容错率。这一步能避免大量无用功。
举个例子,“AI自动生成地形”这个需求听起来很酷,但如果你不问清楚“生成的地形用于游戏场景还是用于地质模拟”,做出来的方案可能完全跑偏。游戏场景要的是美观、随机、低延迟,可以用生成式方法跑实时推理;地质模拟要的是符合物理规律,那就得走数值模拟路线,AI只做参数拟合。同一个需求词,边界不同,技术栈完全不同。
AI工程里,最贵的错误不是代码写错,而是问题定义错了。代码错了修复只要几小时,方向错了返工要几周。
1.3 给自己定一条从零开始的主线
如果你真的是从零开始,不要指望一个月学会所有东西。我建议按四个阶段推进,每个阶段都有一个“可验证的结果”作为里程碑:
- 第一周:跑通一个最小的模型调用闭环,不管是云API还是本地模型,写一个脚本能输入文本、输出文本。
- 第二周:做提示词工程,把固定任务的输出质量调到“可接受”,并写清楚Prompt的版本。
- 第三周:接数据,做一个带检索的问答或文档处理流程,把“上下文”的概念落地。
- 第四周:写评测脚本和简单的部署接口,把项目从脚本升级成服务。
每个阶段结束都问自己一句:现在这个东西,如果交给别人用,需要配多少使用说明?如果答案是“只有我能跑”,那说明工程化还没完成。
2. 环境与最小闭环:先把模型跑起来
不管你的目标多宏大,AI工程的第一步永远是“让模型在你的机器上或者你的账号里跑起来”。这一步看似简单,但我在带人时发现,几乎所有初学者都会卡在环境问题上:Python版本不对、依赖冲突、CUDA装不上、模型下载到一半中断。所以我强烈建议:第一周不要上难度,跑通一个最小闭环比什么都重要。
2.1 云API还是本地部署,别盲目跟风
现在很多人一聊AI工程,开口就是“本地部署”。但本地部署不是目的,而是手段。我给一个很实际的选型逻辑:
- 只想学习和验证想法,用云API最快,按量付费,不需要买显卡。
- 数据敏感、必须内网运行,或者长期高频调用算下来云API太贵,才考虑本地部署。
- 想深入理解模型推理原理,本地部署一个小模型(7B-14B级别)足够你折腾,没必要一上来就弄70B的大模型。
我把常见选择整理成一张表,方便对照:
| 方案 | 起步成本 | 单次调用成本 | 隐私性 | 学习价值 | 推荐场景 |
|---|---|---|---|---|---|
| 云API | 低 | 按Token计费 | 低(数据出网) | 中 | 快速原型、业务验证 |
| 本地Ollama | 中(看硬件) | 电费 | 高 | 高 | 学习、离线环境、隐私项目 |
| 自建推理服务 | 高 | 固定设备折旧 | 高 | 非常高 | 大规模生产、深度定制 |
我看到不少人在“本地部署”上花了太多时间,结果一个月过去还在调显卡驱动。如果你是初学者,第一次请先用云API跑通业务逻辑,等你要交付了再考虑换本地部署也不迟。
2.2 用Ollama跑起本地模型,五分钟出结果
如果你决定走本地部署,我建议先用Ollama,因为它把模型管理、量化、推理服务都封装好了,不需要手写CUDA代码。以一台16GB内存的普通开发机为例:
# 安装之后,拉取一个7B级别的中文指令模型 ollama pull qwen2.5:7b-instruct # 启动本地服务(默认端口11434) ollama serve拉完模型后,Ollama会自动暴露一个OpenAI兼容的REST接口,用Python就能直接调用:
import requests resp = requests.post( "http://localhost:11434/api/chat", json={ "model": "qwen2.5:7b-instruct", "messages": [ {"role": "system", "content": "你是资深AI工程顾问,回答要简洁。"}, {"role": "user", "content": "什么是提示词工程?"} ], "stream": False, "options": { "temperature": 0.7, "max_tokens": 1024 } }, timeout=120 ) print(resp.json()["message"]["content"])我特意把temperature和max_tokens亮出来,因为这两个参数是初学者最容易忽略的。temperature控制随机性,max_tokens控制最长输出长度。做抽取类任务时我会把temperature降到0.2以下,让输出稳定;做创意文案时才会调高到0.8以上。
2.3 工程化的第一步,是把调用封装成可切换的客户端
跑通上面的代码之后,很多人的习惯是直接把这段代码复制到各处用。这恰恰是“非工程”的写法。你要做的第一件工程化改造,是把模型源变成可配置项。
我一般会写一个LLMClient类,支持通过环境变量切换Ollama、OpenAI兼容服务、或者未来要换的其他端点:
import os import requests class LLMClient: def __init__(self, provider="ollama"): self.provider = provider self.api_url = os.getenv("LLM_API_URL", "http://localhost:11434/v1/chat/completions") self.api_key = os.getenv("LLM_API_KEY", "EMPTY") self.model = os.getenv("LLM_MODEL", "qwen2.5:7b-instruct") def chat(self, messages, temperature=0.7, max_tokens=1024): headers = {"Authorization": f"Bearer {self.api_key}"} payload = { "model": self.model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens } resp = requests.post(self.api_url, json=payload, headers=headers, timeout=120) return resp.json()["choices"][0]["message"]["content"]这一小步的意义不是“代码更优雅”,而是从今天起,你的AI代码不再和某个具体平台绑死。今天用Ollama,明天换成API,只需改环境变量。这放在生产环境里是必须的,否则你每次换模型都要全局改代码。
3. 提示词工程与上下文管理:AI应用的地基
跑通模型之后,你很快会发现一个真相:模型本身只是引擎,真正决定业务质量的是你怎么组织输入输出。提示词工程(Prompt Engineering)在这个阶段就是一个绕不开的核心技能。我见过太多人把提示词工程理解为“把话写得详细点”,但真正做工程,它需要系统性和可评测性。
3.1 提示词的“四层结构”
我总结过一套提示词的“四层结构”,每次都按这个顺序去写,能减少大量无效返工:
- 角色:模型以什么身份回答问题。不是简单地加一句“你是一个专家”,而是要说明这个角色拥有的信息边界。
- 任务:一句话说清楚你要它做什么,越具体越好。不要说“帮我优化一下”,要说“把这段产品简介压缩到100字以内,保留功能亮点和适用场景”。
- 约束:明确不做什么。例如“不要输出Markdown格式”“不要解释你的思考过程”“如果信息不足,直接回答不知道”。
- 示例:给一个输入和输出的对照样本。这一步对模型效果的影响最大,比角色描述重要得多。
举个例子,我现在写抽取类Prompt,模板大概是这样的:
你是合同审核助手,只负责从合同文本中抽取关键信息。 抽取以下字段:合同编号、签署日期、合同金额、甲方名称。 输出为JSON格式,不要输出任何解释。 如果某个字段在文本中不存在,值为null。 示例: 合同文本:甲方北京某某科技有限公司与乙方上海某公司于2023年5月1日签署编号HT-2023-001合同,金额为人民币五十万元整。 输出:{"contract_no": "HT-2023-001", "sign_date": "2023-05-01", "amount": 500000.00, "party_a": "北京某某科技有限公司"}为什么示例如此关键?因为当前主流大模型的训练方式决定了它们非常擅长“模式补全”,你给出示例,它就是在做模仿。没有示例,它靠猜测;有了示例,它靠对照。这是提示词工程里性价比最高的投入。
3.2 Token预算:别让模型“忘了”前面的内容
提示词工程不光是写话术,还要算内存账。每次调用模型时,上下文窗口是有限的。假设模型上下文是8192 Token,系统提示词占500,历史对话占2000,输出预留1000,那你真正能用来放“参考资料”的空间只有不到5000 Token。
这里有个工程公式我每次都用:
可用上下文 = 模型窗口总大小 - 系统提示词长度 - 历史对话长度 - 输出预留长度初学时最容易犯的错,是把整篇文档塞进Prompt,结果发现模型“答非所问”。这不是模型笨,是它把前面的内容“遗忘”了。解决思路有三个:截断、压缩、检索。截断最直接但丢失信息;压缩对长文档有效但增加一次前处理调用;检索(RAG)是最工程化的方案,后面会专门讲。
3.3 AI Agent:从单轮问答到多步任务
当你把单轮问答做稳了,自然的下一步是让AI“做事”,而不是“回答问题”。这就是AI Agent的范畴。我理解的Agent,核心是一个循环:模型根据目标思考下一步要调用什么工具,然后执行工具,再根据结果决定是继续还是收尾。
这个结构并不神秘,可以用一个极简的伪代码表达:
def agent_loop(task, tools, llm, max_steps=5): messages = [{"role": "system", "content": "你是一个能调用工具完成任务的小助手。"}, {"role": "user", "content": task}] for _ in range(max_steps): reply = llm.chat(messages) action = parse_action(reply) # 解析出工具名和参数 if action.is_final(): return action.output result = tools[action.name](**action.args) messages.append({"role": "assistant", "content": reply}) messages.append({"role": "tool", "content": str(result)}) return "max steps exceeded"真正生产级的Agent要比这复杂得多,但核心循环不会变。这里值得参考的是行业里公开的一些智能体训练思路,比如通过可验证的结果反馈来强化模型的规划能力——把“任务完成得对不对”变成一种可计算的奖励信号。工程侧对应的做法是:给Agent加上结果校验器,不让它无限试错,而是让校验器判断当前结果是否满足要求,不满足就重规划,满足就输出。这套“校验-重试-终止”机制比单纯依赖模型的自觉要可靠得多。
3.4 把Agent变成可复用工作流
Agent灵活,但也正因为灵活,它不可控。工程上我不建议直接在生产环境放一个自由度很高的Agent,而是把高频路径固化成工作流(Workflow)。工作流像餐厅的标准化SOP,Agent则像自由发挥的私厨,各有各的场景。
一个典型的工作流节点链可能是:内容分类 -> 生成摘要 -> 提取结构化信息 -> 写入数据库。每一步都是独立的模块,模块之间通过明确的输入输出契约连接。这样做的最大好处是:出错时你能定位到具体节点,而不是对着一个黑盒束手无策。
4. 工程落地实战:从Demo到可交付系统
我从一开始就强调“可交付”。什么叫可交付?别人按照你的文档部署之后,不需要你现场指挥,系统能自己稳定运行,出了问题有日志可查。这一节我们用一个文档问答应用作为贯穿案例,把从框架选型到部署观测的全流程走一遍。
4.1 框架选型:别被工具绑架
现在市面上AI应用框架一大堆,Spring AI、LangChain、LangGraph、LlamaIndex,还有各种国产编排平台。我选框架的标准只有一个:团队里大多数人能上手维护。
- Java团队优先考虑Spring AI,因为它的抽象风格和Spring生态一致,接入已有的Spring Cloud基础设施很顺。
- Python团队、追求快速原型,LangChain/LangGraph资料多、社区大,适合验证想法。
- 如果你的团队很小,业务逻辑也不复杂,我建议连框架都别上,直接用HTTP封装+函数调用,反而维护成本最低。
PyCharm这类IDE自带的AI插件也可以用起来,但定位是“写代码时的辅助”,不是“工程架构的一部分”。我做这个项目时用AI插件辅助写了不少样板代码,但架构决策始终是自己定的,插件生成的代码我也会逐行审查。
4.2 文档问答应用的完整流水线
文档问答是入门AI工程最好的练手项目。它的技术栈覆盖了解析、切分、向量化、检索、生成、评测,几乎跑了一遍AI工程的核心环节。
我一边实现一边讲参数选择的理由:
# 1. 文本解析 from langchain_community.document_loaders import PyPDFLoader loader = PyPDFLoader("contract.pdf") pages = loader.load() # 2. 文本切分(关键参数:chunk_size和overlap) from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=512, # 每个块大约512字符 chunk_overlap=50 # 前后重叠50字符,避免语义断裂 ) chunks = splitter.split_documents(pages)chunk_size为什么是512不是2048?因为向量检索的“精确度”是跟块长度强相关的。块越长,一个块里包含的无关信息越多,检索命中的“精准度”就越差。块太短,比如128,又会把完整语义切开。512是我在大量中文文档上试出来的一个平衡点,遇到明显段落边界较大的文档,我会调到768。
接下来是向量化和检索。不要把向量化也想得太神秘,它就是给每段文本计算一个语义向量,检索时计算问题向量与文本向量的相似度。
# 3. 向量化与检索 from langchain_huggingface import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5") vectorstore = Chroma.from_documents(chunks, embeddings) # 4. 检索时返回最相关的4个块 docs = vectorstore.similarity_search(query, k=4)这里k=4是另一个值得解释的参数。检索返回的块数量不是越多越好,因为生成阶段所有返回块都会塞进上下文,块太多会稀释关键信息,也会增加Token消耗。4到6个块在大多数文档问答场景下是性价比最高的区间。
最后把检索结果拼进Prompt,交给大模型生成回答。整个RAG(检索增强生成)流程就闭环了。相比直接把整篇文档塞给模型,RAG的响应速度更快、引用更准确,而且文档更新时只需要重建向量库,不用重训模型。
4.3 没有评估就没有优化
很多项目的失败点在“感觉”:感觉回答变好了,感觉效果不错。但感觉不能上线。我在自己的项目里强制要求:任何Prompt改动和流程改动,都必须跑一遍评测脚本对比。
具体做法不复杂:
- 准备20到50条典型的测试问题,这个集合叫Golden Set。
- 每一条标注标准答案或评分标准。
- 模型回答后,用大模型当裁判(LLM-as-judge)打分,或者用关键词/语义相似度算分。
- 对比改动前后的分数,用数据说话。
下面是一个极简的评估脚本思路:
from rouge_score import rouge_scorer scorer = rouge_scorer.RougeScorer(["rougeL"], use_stemmer=True) def evaluate(questions, answers, reference_answers): total_score = 0 for q, pred, ref in zip(questions, answers, reference_answers): scores = scorer.score(ref, pred) total_score += scores["rougeL"].fmeasure return total_score / len(questions)Rouge-L只是词面重叠的一个近似,更全面的评估还要加入语义相关性、忠实度(是否基于检索内容而不是编造)。但机制比指标更重要:你有了这套评估机制,每次修改都能量化效果,而不是靠玄学。
4.4 部署与观测:最后十公里的坑
Demo做完,部署又是一个深坑。我踩过最典型的问题有两个:一是流式输出做不好,用户等十秒才看到完整回答,体验很糟糕;二是完全没有日志,模型开始胡说八道时根本不知道是哪次请求、哪个参数引发的。
工程上至少要补三件事:
- 用FastAPI包一层HTTP接口,开启流式输出。
- 记录每次请求的模型名、输入Token数、输出Token数、延迟、temperature参数。
- 加一个简单的限流,防止内部调试脚本把服务打挂。
from fastapi import FastAPI from fastapi.responses import StreamingResponse import json app = FastAPI() @app.post("/chat") def chat(request: dict): messages = request["messages"] def generate(): # 模拟流式返回 yield "正在处理" result = llm_client.chat(messages) yield result return StreamingResponse(generate(), media_type="text/plain")日志这块我用的方案很简单,写一个装饰器,把每次调用的关键信息追加到结构化日志文件里。上线后你一定会感激自己多写的这几十行代码。
5. 常见问题与排查技巧实录
写到这里,我不打算回避那些坑。这一节是实战记录,把我在“ai-engineering-from-scratch”这条路线上反复踩过、也帮别人排查过的问题列出来,附上排查思路。
5.1 本地部署:显存不足和速度慢
本地部署最不缺的就是问题。我的经验是部署前先查清楚参数规模和量化等级。拿7B模型来说,不同的参数精度对显存的占用差别很大:
| 模型与量化 | 显存占用估算 | 适用硬件 |
|---|---|---|
| Qwen2.5 7B Q4_K_M | 约5GB | 8GB显存可跑 |
| Qwen2.5 7B FP16 | 约14GB | 16GB显存起步 |
| Qwen2.5 14B Q4_K_M | 约9GB | 16GB显存可跑 |
| Qwen2.5 14B FP16 | 约28GB | 32GB显存起步 |
如果显存不够,优先考虑4-bit量化模型,不要硬塞FP16。如果是纯CPU跑,7B模型速度会很感人,但你要知道这个限制是正常的,别以为是自己配置错了。用Ollama时可以用OLLAMA_GPU_DISCOVERY环境变量控制GPU,也可以用ollama ps查当前加载了哪些模型。
5.2 上下文越长,效果反而越差
很多人以为给模型的信息越多,回答越准确。实际上LLM对长上下文的中间部分注意力是弱的,你把海量资料塞进去,它可能会“看漏”关键信息,还会提高Token成本。
解决办法不是硬堆,而是先检索后生成。把“你要找的信息”从长文中筛出来,再喂给模型。我们在文档问答里的RAG流程就是这个思路。如果检索不到好的片段,宁可让模型说“资料中没有相关信息”,也不要强行编一个答案。
5.3 提示词越改越乱:把Prompt当成代码管
我见过最典型的“提示词崩溃”场景是:改到第三版之后,之前调好的任务反而变差了。原因是没有版本管理。Prompt和代码一样,应该纳入版本控制,而且要有测试用例。
我的习惯是每个Prompt文件都带一个examples/目录,里面放输入和期望输出。每次改Prompt,跑一遍全部示例,任何一个不通过就打回重调。这是工程化思维在提示词领域的具体应用。你不需要花大价钱买提示词管理工具,一个Git仓库配一个测试脚本就足够了。
5.4 开源模型和闭源API的选择误区
不要因为“开源免费”就倾向本地部署。开源模型的完整成本包括:显卡折旧、电费、运维时间、效果调优投入。把台账算清楚再决定。我见过一个团队花了三周部署了一个本地模型,效果还是比不上商用API,最后回去用API了。这并不是说本地部署不好,而是它的价值在隐私、离线、可定制,不在“免费”两个字。
反过来,如果你要做深度定制微调,那闭源API就帮不上忙,本地开源模型是更合理的选择。关键还是要回到需求本身。
6. 最后,我个人的一点体会
沿着“ai-engineering-from-scratch”这条路线走下来,我最大的体会是:从零开始做AI工程,最难的并不是技术本身,而是把一个模糊的想法拆成一个个可验证的小任务。第一周只解决“模型能不能跑”,第二周只解决“输出质量能不能稳定”,第三周只解决“数据能不能接上”,第四周只解决“服务能不能交付”。每个阶段都是一个小闭环,每个闭环都有明确的产出。这些年我帮很多人看过项目,凡是陷入泥潭的,几乎都是因为想一步到位、跳过了某个环节。如果你也在走这条路,我的建议是不要追求第一版就完美,先把最小闭环跑通,再谈优化。跑起来,你就已经超过大多数停留在“想”的人了。