前段时间我把自己的一个项目命名成ai-engineering-from-scratch,本意是"从零开始做AI工程",结果一个朋友看到后问我:这不是"从零开始学AI"的课程笔记吗?我愣了一下,发现这个误解其实很普遍。很多人以为 AI 工程是"学会用 AI 工具",或者"看懂几篇模型论文",但真正上手后才发现,难点几乎全在engineering这个词上——稳定、可评估、可控、成本可接受地交付一个 AI 功能,而不是"调通一次对话"。
我身边不少朋友收藏了几十个 AI 工具网站、买了提示词模板、看了 Agent 架构图,三个月后再问,还是没能让一个 AI 接口稳定地跑在自己的业务里。问题不在他们不努力,而在没人告诉他们:这条路应该先走哪几步、每一步做到什么程度才算合格。所以我决定把从零到能交付一个 AI 功能的能力链路完整梳理一遍,送给那些准备认真做 AI 工程,而不是停留在"玩 AI"层面的读者。
1. 先拆清楚:"AI工程"和"用AI工具"根本是两回事
在开始之前,我花了很大力气才想明白这件事。ai-engineering-from-scratch里的 "engineering" 不是修饰词,它是主语。AI 只是你手里的一个组件,工程才是你要搭建的系统。
1.1 我以为的起点和实际的起点差距很大
我在立项初期翻了很多资料,差点把自己的第一个里程碑定成"读完整本深度学习花书",理由是"不懂模型原理怎么做AI工程"。现在回头看,这个想法差点把我劝退。
做个类比:你想开车通勤,需要的是交规、路感和油门刹车的配合,而不是先读完整本内燃机原理。真正的模型训练、损失函数、梯度下降,那是"造车"的人关心的事。我们绝大多数人做的是"开车":调用成熟的模型能力,把它接进业务流程里。
这不是说底层原理毫无价值,而是说它不该是起点。AI工程的起点是"用起来",不是"造出来"。
1.2 AI工程要碰的四层东西
按照我后来的实践,一个完整的 AI 工程能力栈可以粗分成四层。每一层的目标和关注点完全不同:
| 层级 | 核心问题 | 典型动作 |
|---|---|---|
| 模型层 | 用什么模型,能力边界在哪 | 选型、对比测试、了解上下文长度和输出限制 |
| 数据层 | 给模型喂什么,怎么喂 | 上下文组装、知识库切分与检索、few-shot 样本 |
| 流程层 | 一次调用怎么变成一套流程 | 重试、缓存、状态机、工具调用、Agent 编排 |
| 系统层 | 怎么保证"能用"且"可维护" | 评估集、成本监控、日志追踪、隐私保护 |
我见过很多人把全部精力放在第一层,天天换模型、比参数,却忽略了后面三层。结果就是"Demo 一时爽,上线火葬场"——单次调用看着挺聪明,放进业务流程里就各种不听话。
1.3 为什么不建议从算法论文开始
"from scratch" 这个短语有迷惑性。它确实有"从零开始"的意思,但在工程领域,它更接近"搭出一个完整可用的最小系统",而不是"把每个底层组件都亲手造一遍"。
我的建议是:把论文阅读和源码研究放到跑通两个完整项目之后。那时候你对"上下文窗口处理的是什么""结构化输出卡在哪""Agent 决策为什么不可控"会有切肤之痛,再看论文一眼就能抓住重点。反过来,先啃论文再碰代码,大概率会在第三周就放弃。
2. 第一个项目应该有多小:最小闭环跑起来再说
如果让我给"from scratch"定义一个真正的第一步,那就是:在一个小时内,用代码完成一次真实的模型调用,并且让返回结果能被程序正确处理。
2.1 用20行代码把一次对话打通
我当时用的是 Python,注册了服务商的开发者平台,拿到 API Key 之后,第一段代码大概长这样:
import os from openai import OpenAI client = OpenAI( api_key=os.environ["API_KEY"], base_url=os.environ.get("API_BASE_URL", None) ) response = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": "你是一个数据提取助手,只输出JSON。"}, {"role": "user", "content": "提取下面这段客户反馈中的问题类型和紧急程度:今天发货又延迟了,客服也不回消息。"} ], temperature=0.2, ) print(response.choices[0].message.content)这段代码没什么高深的,但它完成了三件大事:第一,你证明了 API 通路是通的;第二,你第一次用temperature这个参数控制了输出的确定性;第三,你第一次意识到,模型返回的是一段文本,不是一个 Python 对象——后面所有麻烦都从这里开始。
2.2 理解token、计费和延迟,这三个数字决定后续所有设计
初次跑通之后,我看了一眼账单和日志,才意识到"AI工程"和"普通API开发"最不一样的地方:每一次调用都在烧钱,每一次输出都有延迟,每一个字都占上下文空间。
token 是模型处理文本的基本单位。英文单词大致一个词一两个 token,中文通常一个汉字对应一到两个 token。所以上面那段 20 行的代码,每次调用差不多消耗 100 到 200 个 token。
成本公式很简单:每次调用成本 = 输入token数 × 输入单价 + 输出token数 × 输出单价。这还没算重试和多次 Agent 循环的成本。延迟也一样,输出 token 越多,等待越久,因为模型是一个词一个词"蹦"出来的。
| 参数 | 作用 | 建议初值 |
|---|---|---|
| temperature | 控制随机性,越低越确定 | 0~0.3,提取类任务用低值 |
| max_tokens | 限制输出长度 | 按任务估算,别给太大 |
| model | 模型能力和成本的权衡 | 先用小模型跑通,再按需升级 |
2.3 把"人话对话"升级成"程序接口"
跑通调用之后,我做的第一件事不是加功能,而是加"把输出变成数据"的代码。最简单的方式是要求模型输出 JSON,然后在代码里json.loads解析,解析失败就走重试或人工兜底:
import json content = response.choices[0].message.content try: result = json.loads(content) except json.JSONDecodeError: result = {"raw": content, "error": "parse_failed"}这一步的价值在于:你不再把模型当成聊天机器人,而是当成一个"不稳定但不笨的接口"。从这一刻起,你才开始做工程。
3. Prompt不是靠灵感,是把它当API去设计
很多人以为提示工程就是"把话说得漂亮一点"。我一开始也这么觉得,直到我在同一个任务上换了十几个说法,效果忽上忽下,才发现根本问题在于:我没有把提示词当成一份需要严格维护的技术资产。
3.1 为什么我从"背模板"改成"拆字段"
网上能找到大量提示词模板,什么"扮演一个资深分析师"、"请一步一步思考"。抄过来用,时灵时不灵。后来我发现,与其被模板牵着走,不如把提示词当成一段有结构的配置,拆成固定字段:
- 角色定位:这个模型在这个任务里扮演什么角色
- 任务描述:它要完成的具体事情,最好一句话说清
- 输入内容:用户提供的原始数据,用分隔符包起来
- 约束条件:什么不能做,比如"不要编造不存在的订单号"
- 输出格式:字段名、类型、是否必填
- 示例:一两个输入输出示例,帮助模型对齐
我在代码里通常这样组织提示词,方便维护和版本管理:
[角色] 你是一个细心的客户反馈整理助手。 [任务] 从用户反馈中提取:问题分类、紧急程度、关键诉求。 [约束] 只输出合法JSON,不要输出任何其他文字。 如果信息缺失,字段值为 null。 [输出格式] {"category": string, "urgency": "high|medium|low", "request": string} [示例] 输入:东西收到了但少了一个配件。 输出:{"category": "缺件", "urgency": "medium", "request": "补发配件"}这种结构化写法的好处是:出一版,改一版,你永远知道上一个大版本发生了什么变化。提示词从"玄学"变成了"可迭代的配置"。
3.2 few-shot 和结构化输出的真正作用
模板里那段"示例"就是 few-shot,意思是"给模型看几个例子,告诉它你要什么"。它的作用不是提供知识,而是统一风格和格式。模型看到第一个例子是 JSON,它更倾向继续输出 JSON。
但 few-shot 不是越多越好。每加一个示例,输入 token 就增加一段,成本往上涨,响应时间也拉长。我自己常用的量级是两到三个示例,够让它对齐格式就行。
同时,我建议在代码层做"双保险":提示词里要求 JSON,代码里仍然做解析兜底和字段校验。把提示词当成"提高正确概率的手段",而不是"保证一定正确的承诺"。
3.3 一个具体的提示词版本迭代案例
我做过一个"从客户评价里提取三要素"的小功能,迭代过程大概是这样:
| 版本 | 我改了什么 | 结果 |
|---|---|---|
| v0.1 | 一句话要求:"提取问题类型、紧急程度和诉求" | 输出格式飘忽不定,有时是散文 |
| v0.2 | 加了"只输出JSON" | 格式稳定了一些,但字段名不固定 |
| v0.3 | 明确了字段名和类型 | 格式基本稳定,偶尔有多余文字 |
| v0.4 | 加了两个示例 | 准确率明显上升,格式 95% 以上合规 |
每一版改完,我都拿同一批测试样本跑一边,对比结果的正确率和格式合规率。没有数据支撑的"感觉变好了"都不算优化。
3.4 提示工程的边界在哪
提示词不是万能的。我遇到过三类问题靠提示词怎么改都没用:一是任务本身超出模型知识边界,它没有这个信息;二是输出要求过于复杂,字段多到模型容易漏;三是矛盾要求,既要求"详细"又要求"简短"。
遇到这类情况,别再死磕提示词了。要么把任务拆成更小的子步骤,要么引入外部知识检索,要么换更大的模型。提示词解决不了的问题,工程结构可以解决。
4. 把模型包进流程:从一个调用到一个会工作的系统
跑通单次调用之后,下一步是把它变成"不会突然挂掉"的服务。这一章讲的全都是经典工程手段,只不过服务对象从数据库变成了模型。
4.1 模型应用也需要重试与缓存
模型 API 不是 100% 可靠的,超时、限流、临时 500 都很常见。而 AI 应用里"重试"还有个特殊意义:同样的输入,模型可能因为随机性返回不同结果。如果你的业务对一致性有要求,重试策略就得小心,有时候重试反而会引入新错误。
我采用的最简方案:
import time from tenacity import retry, stop_after_attempt, wait_exponential @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=10)) def call_llm(safe_params): ...缓存也一样。完全相同的请求(同样的 prompt、同样的模型参数)不应该重复烧钱。我在系统里加了一个简单的精确缓存:以 prompt 和参数的哈希作为 key,第一次请求落库,后续命中直接返回。效果立竿见影,实验阶段能省下三成以上的 token 费用。
4.2 用数据而不是感觉调优:建立你的第一个评估集
这是我觉得整个ai-engineering-from-scratch过程中最重要的一步:给 AI 功能建一套测试集。没有评估集,你所有的优化都是在"盲调"。
我当时的做法很朴素:从真实请求里攒了 20 条样本,每一条都手工标注了期望输出。然后写一个脚本,把提示词版本跑在全部样本上,统计正确率、格式合规率、失败原因分类。20 条不多,但足够暴露大部分问题。
| 失败类型 | 常见原因 | 应对思路 |
|---|---|---|
| 格式错误 | 提示词没说清 / 模型偶尔抽风 | 加 few-shot、代码兜底解析 |
| 信息遗漏 | 任务步骤太多,模型顾此失彼 | 拆分子任务,一次只做一件事 |
| 幻觉补充 | 模型编造了原文没有的信息 | 明确"禁止编造",必要时换低 temperature |
有了这 20 条样本之后,我再也不纠结"这版提示词好不好",直接跑一遍数据,用结果说话。
4.3 让 AI 进入你的代码:function calling 的 JSON 契约
如果说提示词是让模型"说话",function calling 就是让模型"动作"。它做的事情很简单:定义一组函数,告诉模型"你可以调用这些函数",模型在需要时返回一个结构化的调用请求,由你的程序真正执行。
典型的工具定义长这样:
{ "type": "function", "function": { "name": "query_order_status", "description": "根据订单号查询物流状态", "parameters": { "type": "object", "properties": { "order_id": {"type": "string"} }, "required": ["order_id"] } } }流程上要做一次循环:模型返回"我想调用 query_order_status" → 你的代码执行这个函数 → 把真实结果回传给模型 → 模型基于结果组织最终答案。
这里有一个我踩过的重要教训:永远让代码去执行函数,而不是相信模型的任何直接输出。模型只负责"决定"调哪个函数、传什么参数,真正读写数据库、发消息的必须是你的代码。
4.4 多轮对话的记忆:别把历史一股脑塞进去
做对话类功能时,最容易犯的错误是把所有历史消息都塞进上下文。上下文窗口是有限的,而且成本随 token 数线性上涨,历史太长还会把模型的注意力稀释掉。
我试过三个方案,按复杂度排序:
- 滑动窗口:只保留最近五轮对话,超出部分直接丢弃。实现最简单,适合闲聊场景。
- 摘要压缩:每进行几轮,先把前面的对话总结成一小段摘要,塞进系统提示词。适合需要长期记忆但不需要逐字记忆的场景。
- 外部检索:把历史消息向量化存库,每轮按相关性捞回最相关的几条。适合知识库类应用。
从零起步的人,我建议先做滑动窗口,把链路跑通,再逐步升级。别上来就上重型方案。
5. Agent:一层一层剥开"自主"的外壳
Agent 是这两年最火的词,也是最容易被包装成玄学的词。我一开始对 Agent 持怀疑态度,觉得不过是"循环调用模型"的噱头。后来自己实现了一个最小的 Agent 循环,才发现它的核心思想确实朴素,但朴素不代表简单。
5.1 为什么我一开始不信 Agent,后来又真香
早期我看到各种"Agent 自主规划、自我进化"的宣传,第一反应是抗拒。直到我读到 ReAct 模式的描述:Agent = 思考(Reasoning) + 行动(Act)的循环——模型根据当前状态决定下一步做什么,调用一个工具,观察结果,再决定下一步。
这个描述击中了我的痛点:遇到分支不确定的任务时,传统的硬编码流程根本写不全所有情况,而 Agent 可以在运行时动态决定走哪条路。它不是玄学,它是把"决策"从程序员手里转移到了模型手里。
5.2 ReAct 循环的最小实现
我用伪代码记录过这个循环,核心逻辑非常简单:
状态 = 初始消息 工具列表 = [查订单, 查库存, 发消息] for 步数 in range(最大步数): 决策 = 调用模型(状态, 工具列表) if 决策 == "输出最终答案": break 结果 = 执行工具(决策.工具名, 决策.参数) 状态 = 追加观察结果(状态, 结果)每一步里,模型看当前状态,决定调用哪个工具,你的程序执行工具并返回结果,模型再接着思考。这个循环之所以有用,是因为模型能在"现实反馈"的基础上继续推理,而不是凭空想象。
5.3 让 Agent 可控的护栏设计
Agent 最大的问题不是"蠢",而是"太自由"。让模型自己决定调用哪些工具,意味着它可能调用你没打算让它碰的东西。我的护栏设计是四件套:
- 工具白名单:模型只能调用你注册过的函数,天然碰不到别的系统。
- 最大循环轮数:限制 5~10 轮,防止出现死循环。
- 成本上限:每次执行记录 token 消耗,超过阈值立即熔断。
- 关键操作人工审批:涉及删除、转账、发送消息这类高危动作,模型只能生成"申请",由人来确认。
这四条每一条都救过我。有一次 Agent 在调试数据时差点把测试库的表清空,就是因为我在工具定义里没有限制写操作,后来加了审批才兜住。
5.4 从单 Agent 到串行工作流:把任务拆成节点
做完单 Agent 循环后,我的下一个顿悟是:大部分真实业务不需要一个什么都干的 Agent,而是需要一条"工作流"。工作流的每个节点可能是固定逻辑,也可能是模型调用。
举个例子,我做过一个资料整理流水线:
- 意图识别节点:判断用户输入是"查资料"还是"写摘要"
- 数据提取节点:从原文抽出关键实体
- 生成节点:用提示词生成结构化结果
- 校验节点:检查 JSON 合法性、必填字段完整度
这条流水线每一步都是确定的,只有生成节点用到了模型。它的好处是:每一步都可以单独调优、单独测试、单独替换。能用工作流解决的问题,我绝不用自由 Agent 解决。
6. 真正拉开差距的工程细节:评估、成本、可观测性
跑通 Demo 的人很多,能把 AI 功能稳定上线的人很少。差距不在提示词技巧,而在三个容易被忽视的工程细节:你有没有测试集、你知不知道钱花在哪、你能不能事后复盘。
6.1 评估集:给 AI 应用建一套自己的测试
通用 benchmark 测的是模型能力,不是你的业务效果。你的业务有自己的特殊性:你的客户评价长什么样、你的订单编号规则是什么、你的用户喜欢用什么词——这些必须靠自有评估集。
我的建议是从 20 条开始,逐步扩充到 100 条以上。每条样本包含:
- 输入(原始文本)
- 期望输出(手工标注)
- 允许的偏差范围(比如字段值同义是否算对)
评估方式可以分层:先做规则校验(JSON 合法、字段存在、枚举值正确),再做模型打分(用一个强模型当裁判,对比输出和期望的语义一致性),最后人工抽检。三层跑下来,你对"这版改动是变好还是变坏"就有了数。
6.2 成本控制:token 在哪里飞走的,就在哪里抓回来
我一开始根本不看账单,直到一个月底发现费用超出预期三倍。逐条翻日志后,问题集中在三个地方:
- 系统提示词写得过长:一段 1000 字的角色设定,每次请求都带着,再乘以调用量,就是一大笔钱。
- 每轮对话都携带完整历史:聊到十轮的时候,十轮的历史成了最贵的输入。
- 无差别的模型选型:简单分类任务也用了最大模型,纯属浪费。
我的调优动作包括:系统提示词精简到 200 字以内、历史改用摘要压缩、简单任务用小模型、引入语义缓存。这三个动作加起来,月度成本降了接近一半。
6.3 日志与可观测:当 AI 出错时,你要能事后复盘
模型应用的调试和传统后端不一样。传统后端报错有堆栈、有固定逻辑,模型应用报错经常是"它没报错,但答错了"。这时候如果没有日志,你连它是怎么错的都不知道。
我给每次请求都分配了一个request_id,在日志里记录:
| 字段 | 说明 |
|---|---|
| request_id | 一次完整任务的唯一标识 |
| model | 用了哪个模型、哪个版本 |
| prompt_hash | 提示词内容的哈希,便于对比版本 |
| tokens | 输入/输出 token 数 |
| latency | 耗时 |
| raw_output | 模型原始返回(需脱敏) |
有了这些日志,一次线上事故就能完整回溯:找到request_id→ 看链路里每一步的输入输出 → 定位是哪一步的提示词或上下文设计导致了错误。我还养成了一个习惯:把每次"错误但没报异常"的案例单独存一份。这些案例是教程里学不到的,是你自己的专属训练数据。
7. 如果只允许我说一句话的经验
我把这个项目从头走完一遍之后,最想分享的经验是:先跑通最小闭环,再谈优化。很多人卡住的真正原因,不是不懂原理,而是迟迟不肯写那 20 行代码、建那 20 条测试样本。AI 工程的门槛从不在起点,而在"能不能持续迭代"。
最后再分享一个我现在一直在用的习惯:每次模型输出和预期不符,我不着急改提示词,而是先把"错误案例"记下来。攒够十个错误案例之后集中分析一次,你会发现错误模式非常清晰——有的输入就是格式难解析,有的任务就是需要拆成两步,有的场景就是需要换模型。这些一手数据,比任何提示词模板都值钱。
从零开始做 AI 工程,真正难的不是 AI,是"工程"两个字里包含的耐心和系统性。希望这篇拆解能帮你少走几步弯路。