一年前,我在GitHub上建了一个名为ai-engineering-from-scratch的仓库,本意只是整理学习笔记,后来发现它慢慢变成了我完整走通一个AI工程项目的见证。这个项目从零开始,一点一点长成了一整套可复用的AI工程体系:需求拆解、提示词设计、Agent开发、自动评估、生产部署、成本监控,全都有。过程中踩过的坑、推翻的代码、重新思考的架构,比任何教程都值钱。如果你也处于想入门又不知道从哪下手的状态,或者已经会调API但一上线就崩,这篇文章就是写给你们的。我会从一个空目录开始,讲清楚把AI应用变成成熟产品的每一步。
1. AI工程和AI研究根本不是一回事:先纠正几个常见误解
1.1 研究管找上限,工程管守住下限
很多朋友一听到"AI工程"就以为要读论文、复现模型。我最初也有这个误解,结果把大量时间花在看Transformer源码、看微调教程上,最后发现这些在业务项目里几乎用不上。AI研究解决的是"模型还能做多难的事",而AI工程解决的是"模型做的每件事是否稳定、可控、可维护、成本是否可接受"。
举个最直白的例子:研究阶段,模型十次里有一次生成了精彩答案,就能发论文;但到了工程阶段,十次里有一次跑偏,用户就会流失,系统就会被判定为不可用。工程的核心就是守住下限——让随机性被约束在业务可接受范围内。ai-engineering-from-scratch这个项目给我的最大教训,就是先分清"研究性玩法"和"工程性做法"。
1.2 AI工程的能力地图
如果把AI工程能力拆开,我看大致有六块:第一是需求工程,把模糊的"做一个智能助手"拆成可验收的功能点;第二是数据与提示词工程,包括上下文构造、示例编写、输出格式控制;第三是Agent工程,涉及工具调用、状态管理、多步规划;第四是评估工程,建设测试集、指标、回归门禁;第五是部署运维,把模型推理变成高可用的在线服务;第六是成本治理,从Token消耗到算力利用率。
这六块不是线性的,而是一个循环。我在仓库里画了一张能力循环图(后面用文字描述):需求 -> 设计 -> 评估 -> 部署 -> 监控 -> 再回到需求。没有哪一块可以跳过,只要跳过,上线后就会以更痛的方式补课。
1.3 为什么"从零开始"比"看教程"更有效
市面上90%的AI教程都在教你"调用API",教你跑通一个Demo。但Demo和产品之间,隔着一整套工程基础设施。ai-engineering-from-scratch的项目名就是要从零开始,不用任何框架模板,先手动实现一个最简链路,然后再引入工具来优化。这样你才知道哪个环节省掉了什么。
比如一开始我直接用requests调用模型接口,没有封装任何服务。虽然代码很丑,但恰恰是这份丑让我理解了"超时、重试、限流、JSON解析失败"这些问题从哪来。之后再用LangChain、LangSmith等框架,才真正理解它们解决的是什么。从零开始不是要你重复造轮子,而是让你获得判断"车轮子是否合格"的能力。
2. 从空仓库到可运行项目:我的最小工程骨架与选型
2.1 技术选型:不追求时髦,只看场景
当时我手里的条件很简单:一个人、一台带GPU的本地开发机、若干大模型API额度。技术选型上,我最终定了Python 3.11 + FastAPI + Pydantic + pytest。语言用Python没有悬念,AI生态最全,写起来快。Web框架选了FastAPI,因为它自带Pydantic校验和OpenAPI文档,对AI应用这种"既要快速开发又要严格入参校验"的场景很合适。
模型侧没有直接私有化部署大模型,而是接国内几家主流API(DeepSeek、通义、Kimi都跑过)。原因也很直白:个人项目先验证业务逻辑,不需要承担GPU运维成本。等真实用户量上来再考虑推理优化。这个决策帮我省了至少一个月的时间。
2.2 项目目录怎么组织:让所有东西都有归属
我见过很多AI项目,代码全堆在main.py里,prompt写在字符串里,测试没有目录,上了生产根本不敢动。所以ai-engineering-from-scratch从一开始就定了清晰的结构。
ai-engineering-from-scratch/ ├── app/ │ ├── api/ # FastAPI路由 │ ├── core/ # 配置、依赖、全局异常 │ ├── services/ # 业务逻辑,调用LLM │ ├── agents/ # Agent定义与工具注册 │ ├── prompts/ # 提示词模板,按版本管理 │ └── utils/ # 输出解析、重试、缓存 ├── tests/ │ ├── unit/ # 纯逻辑单测 │ ├── integration/ # 调用真实API或mock网络 │ └── eval/ # 评估集与评估脚本 ├── data/ # 本地数据样本 ├── scripts/ # 构建、启动、评估脚本 ├── deploy/ # docker、nginx、systemd配置 └── pyproject.toml这个结构不是凭空想的。prompts/单独建目录,是因为提示词会频繁改动,如果混在代码里,每次改一个标点都要动业务代码,容易引起事故。tests/eval/独立,是因为评估不是简单断言,它需要独立的语料和指标脚本。
2.3 第一版封装:调用LLM也能写出工程感
第一版代码我写了一个非常薄的LLM客户端,重点不是功能多,而是统一处理三件事:APIKey管理、超时重试、JSON解析。
# app/services/llm.py import json import time from typing import Any import requests from tenacity import retry, stop_after_attempt, wait_exponential class LLMClient: def __init__(self, api_key: str, base_url: str, model: str): self.api_key = api_key self.base_url = base_url self.model = model @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, max=10)) def chat(self, messages: list[dict], temperature: float = 0.2) -> str: resp = requests.post( f"{self.base_url}/chat/completions", headers={"Authorization": f"Bearer {self.api_key}"}, json={"model": self.model, "messages": messages, "temperature": temperature}, timeout=30, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] def chat_json(self, messages: list[dict]) -> dict[str, Any]: content = self.chat(messages) # 优先提取 ```json 代码块,防止模型输出多余文字 if "```json" in content: content = content.split("```json")[1].split("```")[0] return json.loads(content)这段代码看起来简单,但包含了三层经验:第一,用tenacity实现指数退避重试,LLM接口在高峰期经常503,不重试就等着被用户投诉;第二,chat_json里先提取Markdown代码块,因为很多模型即使你让它返回纯JSON,它也会忍不住加一段解释;第三,把所有超时统一为30秒,避免某些请求无限挂起拖垮进程。
3. 提示词工程的落地姿势:接口、版本、校验一起上
3.1 把Prompt当成函数签名来设计
我早期写提示词就依托ChatGPT网页,复制粘贴,效果好坏全靠运气。后来我意识到,提示词的本质是"一个不稳定的函数入口"。工程化思路,就是要让这个入口稳定、可预测、可度量。
我的做法是给每个任务定义一个结构化Prompt模板,包含5个部分:角色定义、任务目标、约束条件、输入内容、输出要求。输出要求里必须明确"只输出JSON对象,不要解释",并且给出一个具体的JSON示例。比如做关键词提取时,模板长这样:
你是一个关键词提取引擎。 请从用户的输入文本中提取最多5个关键短语。 约束:不要输出任何解释文字。 输出格式为JSON数组,例如: ["人工智能", "工程实践", "大模型"] 用户输入: {{input}}注意这里我特意给了few-shot示例。模型对"只看要求"的理解远不如"看一个完整例子"来得准确。这个经验在工程里非常重要——少样本示例(few-shot)是提升输出稳定性的性价比之王。
3.2 JSON Schema校验:别让模型输出毁了下游
只让模型输出JSON还不够。比如提取关键词的任务,业务要求返回数组,模型某次返回了数组,某次返回了字符串。为了挡住这种不确定性,我用Pydantic定义了输出模型,并在解析后强制校验。
# app/schemas/keyword.py from pydantic import BaseModel, Field, ValidationError class KeywordResult(BaseModel): keywords: list[str] = Field(..., min_length=1, max_length=5) def parse_keywords(content: str) -> KeywordResult: data = json.loads(content) return KeywordResult(**data)这个校验的意义在于"把错误拦截在早期"。如果模型输出垃圾,就直接抛异常,触发重试;而不是带着垃圾数据往下游走,最后生成一个莫名其妙的报告。我在项目早期吃过教训,模型某次把JSON里的逗号全写成了中文逗号,下游直接崩溃。从那以后,凡是模型输出进入业务流程,一律过Schema。
3.3 提示词也有版本:改名、记录、回滚
提示词改动引发的Bug往往很隐蔽。可能上周效果很好的prompt,今天因为改了半句话,输出格式全变了。所以我把每个prompt文件用Markdown编写,在文件头记录版本、日期、改动原因、评测效果。这不是多余劳动,而是当线上效果突然下降时,能第一时间看出"是不是有人改了prompt"。
ai-engineering-from-scratch里的prompts目录下,我看到每个任务一个子目录,里面有v1.md、v2.md。每次改动,必须配套更新对应的评测脚本结果。这项工作让我的提示词迭代变得可审计,长期价值极高。
4. Agent开发没有想象中难:工具调用与状态管理是核心
4.1 Agent不是玄学:理解成"模型+循环"
AI Agent的术语很多:规划、推理、记忆、工具调用。说实话,卸下概念外壳,核心就是一个循环:模型生成下一步动作 -> 执行动作 -> 把结果拼回上下文 -> 再交给模型决策。像一个不断查资料的实习生,每一步都在问"接下来做什么"。
我在项目里做了一个比较典型的资料查询Agent:用户提问后,Agent先判断是否需要检索知识库,如果需要,就调用一个search_knowledge_base(query)工具,拿到结果后再生成回答。整个过程不是一次LLM调用,而是多次。
4.2 工具注册:让模型安全地使用内部API
给Agent加工具,最忌讳的就是把函数裸奔给模型调用。模型不可信,你给它一个"删除数据库"的工具,它就真可能被prompt injection诱导去执行。工程化的做法是定义工具Schema,白名单式暴露。
# app/agents/tools.py from pydantic import BaseModel class SearchKBInput(BaseModel): query: str = Field(..., description="检索关键词,最好是一个短语") top_k: int = Field(5, ge=1, le=10) TOOLS = [ { "name": "search_knowledge_base", "description": "在内部知识库中检索相关文档片段", "parameters": SearchKBInput.model_json_schema(), } ]这里有两个关键设计:一是参数用Pydantic定义,模型调用工具时如果参数类型不对,无法通过校验;二是top_k限制在1到10,防止某次检索拉回几百条上下文把Token撑爆。模型虽然在决策,但它活动的边界被你严格地约束住了,这就是前面提到的harness engineering思想——像缰绳一样控制模型,而不是放任它自由发挥。
4.3 状态管理:不要让多轮对话变成无底洞
Agent的多轮对话有两种状态:一种是和用户的显式对话历史,另一种是Agent内部的思考历史(包括工具调用和结果)。很多人在实现时把两种状态混在一起,结果上下文越来越长,最后超出模型窗口。
我的做法是把内部思考历史放进一个AgentState对象,只保留最近N轮的推理链,工具结果太长时直接做摘要压缩。这样上下文不至于无限膨胀。同时,给每个会话设置一个max_turns上限,比如最多允许5轮内部循环,超过则强制让模型给出最终答案,防止Agent陷入死循环。
5. 没有评估体系就不要说系统可用:测试集和自动化回归
5.1 LLM应用最大的坑:没有测试集
传统软件测试很好做,一个函数输入输出是确定的,断言一写就完事。LLM应用不行,同一条输入,温度调成0也可能出现细微变化。如果没有一批固定案例,你根本不知道这次改动是变好了还是变坏了。
我为此建了一个tests/eval/cases/目录,里面放了两类评估集。一类是输出格式评估集,纯测模型是否按指定JSON格式输出,包含正常输入和异常输入(如空字符串、超长文本、夹带Markdown的输入)。另一类是业务效果评估集,比如做资料查询Agent,就准备30条标准问题,每条记录期望包含的关键信息点。
5.2 评估指标怎么定:准确率只是起点
针对资料查询Agent,我设计了几个量化指标:准确率(答案是否包含期望信息点)、格式合规率(是否符合JSON Schema)、幻觉率(是否出现知识库不存在的实体)、端到端延迟(从发出请求到收到完整回答的时间)。其中幻觉率我采用半自动方式,先让模型基于答案生成"支持事实",再人工抽检。
这些指标写进scripts/eval.py,每次改Prompt或Agent逻辑后跑一遍,输出效果对比表。这个习惯救了我好几次,有两次优化后准确率提升了,但幻觉率也升高了,多亏评估脚本能立刻发现这个“牺牲可靠性换精度”的苗头。
5.3 把评估接入CI:用自动化门禁拦住回归
评估脚本跑完不算结束,我把它接入了GitHub Actions。每次Pull Request,自动跑单元测试和一组轻量级评估(比如抽20条关键案例),如果格式合规率低于95%或准确率低于上次基准,CI直接失败。这样团队里任何一个人改了prompt或工具逻辑,都能立刻看到是否有回归。
这里要特别提醒一点:评估集不是一成不变的。每过一两周,把线上用户的真实问题加入评估集,形成一个“线上反馈 -> 评估集扩充 -> 回归验证”的闭环。不然评估集慢慢会脱离实际场景,变成自说自话。
6. 生产环境那一道坎:部署、监控、成本优化的实操记录
6.1 从Notebook到在线服务,最少要过几关
本地跑通之后,我信心满满把服务部署到云服务器,结果第一个星期就被打脸。Notebook里跑一次调用不受约束,生产服务则要面对并发、超时、限流、安全、日志。我总结下来,最小可上线清单至少有五件事:进程管理(systemd或容器)、HTTPS网关、API鉴权、结构化日志、优雅关闭。
以部署为例,我最后选择了Docker + systemd的方式。Docker统一了运行环境,systemd负责守护进程和开机自启,配置简单成本又低。对于个人项目和中小业务,这样做比直接上Kubernetes更务实。Kubernetes是很好的技术,但如果只有几十个请求每秒,运维复杂度会反噬开发效率。
6.2 性能优化三板斧:缓存、并发、流式输出
上线后第一周我就发现Token消耗比预想快得多,因为每次请求都在重复传入大段上下文。后来做了三个优化:response_cache缓存层、并发限制、流式输出。效果立竿见影。
| 优化项 | 做法 | 效果 |
|---|---|---|
| 缓存层 | 对用户查询做语义相似的简易匹配,命中后直接返回 | 热门问题Token消耗降低约40% |
| 并发限制 | 只允许最多10个并发LLM请求,其余排队 | 避免限流触发,错误率下降 |
| 流式输出 | FastAPI返回StreamingResponse | 首Token体验从3秒变800毫秒 |
缓存层我没有用向量数据库,而是先做了个简单的MD5+过期时间实现,后来才根据调用数据优化为语义缓存。我的经验是:不要一开始就上重武器,先用最简单的方案解决最痛的点。
6.3 监控不是看仪表盘,而是看“异常模式”
传统的监控指标是CPU、内存、QPS。AI应用除了这些,还要盯三类更有语义的指标:Token消耗速率、请求错误原因分布、输出格式异常率。尤其是格式异常率,如果某版本提示词改动后,这个数字突然从0.1%涨到5%,还没等用户投诉,你就能从日志里嗅到问题。
我写了一个简单的日志中间件,每次LLM调用都会打印模型名、输入Token数、输出Token数、耗时、是否重试、最终是否成功。然后搭配grep和几个统计脚本,虽然没有高大上的监控平台,但定位问题足够了。
6.4 模型灰度发布:永远留一条退路
大模型API厂商经常升级模型版本。某个版本可能在官方的benchmark上表现更好,但到你的具体场景里就是不如以前。所以我做了一个模型路由配置,每个服务使用一个model参数,线上白名单里同时保留旧版本和新版本。
灰度发布的操作方式:先在内部体验环境切到新版本,跑一遍评估集;没问题后,线上按10%流量灰度,用前面提到的评估指标对比三天;确认稳定后逐步扩大到100%。如果效果异常,直接配置一键回滚到旧版本。这个流程给我留了好几次余路。
7. 从单人实验到团队协作:让AI工程在整个组织跑起来
7.1 代码规范和评审:AI项目更需要纪律
一个人写代码时,总觉得规范限制效率。但真的和团队协作,没有规范的AI项目就是灾难。随机性已经让系统很不可控了,如果代码风格、prompt管理、错误处理还各有各的想法,出了问题根本没法查。
我给团队定了几条硬规矩:所有Prompt必须走prompts/目录,禁止在代码里写裸字符串;所有模型输出必须经过Pydantic校验;每次Prompt改动必须附带评估脚本结果;LLM调用必须走统一封装的LLMClient,不允许直接 import 其他SDK。这些规矩不复杂,但能拦下80%的隐性Bug。
7.2 跨角色协作:产品、算法、工程、测试各司其职
AI工程不仅是工程师的事。产品经理要理解模型的能力边界,不要承诺模型做不到的事情;算法工程师要负责模型选型和评估体系;后端工程师要解决服务稳定性;测试工程师则要为非确定场景设计专门的用例策略。
我见过最失败的协作模式,是产品拿ChatGPT上的表现来提需求,工程师为了满足需求开始各种无限制的调prompt,最后双方都精疲力竭。更合理的方式是,先由工程师和算法建一个“能力基线”,给产品看一组真实测试集上的成功失败案例,让产品基于客观事实做需求取舍。
7.3 沉淀公共组件:把经验固化为平台能力
当团队里同时跑三四个AI应用时,重复劳动开始显现:每个项目都要写一套LLM客户端、一套缓存、一套评估脚本。这时就应该把可复用的能力抽到一个公共的ai-platform库中,包括模型网关、提示词管理后台、评估服务等。
ai-engineering-from-scratch项目到后期就承担了这个角色。它的很多模块被直接复制到公司其他项目里,再经过大家的迭代,变成更健壮的公共组件。这也是我认为“从零开始”最大的价值:你亲手写过一遍底层逻辑,后续用任何平台能力时,都知道它在干什么、出了问题怎么排查。
7.4 踩过的经典坑:几个值得反复回看的教训
最后梳理几个我踩过、也看着朋友踩过的典型坑。
第一,永远不要在模型输出里直接拼接SQL或Shell命令,除非你做好了严格的参数白名单和转义,否则一次prompt injection就会让你付出代价。第二,重试策略必须配指数退避,国内很多模型API限流是每秒级别的,固定间隔重试往往会火上浇油。第三,Token成本要按每百万Token的价格拆算到每次请求,很多看似微弱的选择,放大到百万次请求后差距惊人。第四,一个AI服务出问题时,先查输入和输出日志,不要一上来就怀疑模型,大概率是你的上游传了脏数据。
这些坑没有一个是模型“不够聪明”造成的,全都是工程管理上的疏忽。这也再次印证了这篇文章的核心观点:AI工程的难点从来不只是算法,而是把算法稳稳地嵌进真实业务的那一整套功夫。