1. 先聊聊我对“AI Engineering From Scratch”的理解
我正式把这个名字当真,是在自己动手写了三版AI应用、又推倒了两版之后。最早我以为“AI Engineering”就是调API,能把OpenAI的接口接进业务里,让用户问一句、系统答一句,就算入门了。真正做下去才发现,这个领域最难的部分根本不是“调用模型”,而是把模型、数据、工具、评测、成本、异常处理全部捏合成一条稳定的生产链路。名字叫From Scratch,核心意思也很直白:不依赖现成的低代码平台,不套某个成熟框架的脚手架,从选型、写调用层、设计Prompt、接工具、做评测这一步一步搭起来。
这篇文章面向几类人:一是想从“会调接口”走向“能做系统”的开发者;二是团队里刚接手AI项目的核心工程师,需要一个能落地的参考主干;三是不想被各种成熟框架限制、希望理解底层控制的同学。我这里不会讲特别高深的数学,也不会只用理论说服你,全部是跑了多轮任务、线上跑过真实负载之后沉淀下来的取舍逻辑。
从标题也能看出一个态度:AI Engineering 不是“机器学习建模”,也不是“提示词工程”的换皮。它实际是“怎么围绕大模型构建可靠系统”的工程问题。你的模型可以强到逆天,但如果你不会控制输出结构、不会管理调用失败、不会评估效果,那它在生产环境里就是一台昂贵的随机文本生成器。这个领域的技能栈很杂:需要懂一点模型能力边界,需要懂系统设计,需要懂数据清洗,还需要懂成本计算和评测设计。所以我建这个从零开始的工程模板,本质上是在给自己搭一套“可重复、可观测、可迭代”的AI应用底座。
2. 从零搭起之前的全局设计思路
2.1 先分清层:模型层、接口层、应用层、工具层
我从一开始就给自己定了一条规矩:不管项目多小,代码结构必须按层划分。这条规矩让我后面每次迭代都没有翻车。一个典型的AI工程,至少要分成四层:
- 模型层:解决“用哪些模型”,包括主模型、备用模型、小模型。不是所有任务都要上最强模型,后续你会发现分层调用能让成本下降一半以上。
- 接口层:负责把模型能力封装成统一调用入口,屏蔽不同厂商的API差异。这一层决定你的代码是不是容易被某个模型厂商绑架。
- 应用层:承载业务逻辑,比如用户提问的预处理、上下文组装、工具选择、输出校验。这是AI工程师写得最多的部分。
- 工具层:让模型能调用外部能力,比如搜索、数据库查询、计算器、内部文档检索。没有工具层的AI应用,基本就是个聊天玩具。
我最初犯的错是跳过接口层,直接在所有业务流程里散装调用模型API。结果模型升级后,所有业务代码都要跟着改,那真是一次让人头大的重构。如果你从零开始,一定先写一层统一的接口抽象,哪怕它只有两个函数:complete(prompt, schema)和stream_complete(prompt, on_token)。
2.2 为什么不用现成框架,部分场景还是要From Scratch
市面上有非常多的LangChain、LlamaIndex、Flowise之类工具,我为什么还要从头搭?原因很简单:框架抽取的抽象层级,不一定适合你的业务;而且当你遇到诡异问题的时候,不懂底层你连排查方向都没有。框架能帮你省时间,但抽象也会掩盖细节。
举个例子:我当时想实现一个“多步推理工具调用”,框架写起来确实快,几行代码就能把“规划-执行-观察-总结”串起来。可一旦走到生产环境,问题就全来了:中间一步的Token消耗怎么算?工具结果太长截断策略怎么控制?某一步返回不可解析的JSON时,框架默认的处理方式是否符合业务预期?框架给你的是一套通用答案,但AI工程的难点恰恰在于特殊情况太多,通用答案经常不够用。
所以我给出的实践建议是:如果你在探索不适合直接上框架,先手工写一套最简Pipeline,理解每一步的数据流;等你把问题摸清楚了,再用框架辅助。这个“自己先写一遍”的路径,就是“AI Engineering From Scratch”的价值所在。
2.3 确定第一版要覆盖的最小闭环
不要一上来就想做几十个Agent协同的大系统。我踩过的坑非常典型:第一版想直接把“知识库问答 + 自动工具调用 + 多轮对话记忆 + 报表生成”一次做完,结果开发了三个月,连一个环节都没跑到生产环境。后来我调整策略,第一版只做一件事:用户提问 → 判断是否需要工具 → 调用工具 → 组织回答 → 输出结构化JSON。
这个最小闭环跑通之后,我立刻把评测脚本跟上,确保每次改Prompt和改代码都能量化对比。之后再往里面加记忆、加更多工具、加路由策略。记住这句话:AI应用工程化,很多复杂度不是一开始就要解决的,而是你有了稳定基线之后,才能逐步加调节项。没有基线,所有变量都在抖动,你会分不清效果变差是Prompt问题、模型问题还是上下文构造问题。
3. 核心模块怎么搭:从调用封装到Prompt管理
3.1 统一模型接口:再麻烦也值得
我非常推荐在你的项目里建一个model_provider.py,它对外只暴露统一的调用函数。内部再做三件事:读取配置、根据任务路由到不同模型、做统一错误处理。配置大概长这样:
# config.yaml models: main_llm: provider: openai model_name: gpt-4o-mini temperature: 0.2 max_tokens: 2000 extract_llm: provider: openai model_name: gpt-4o-mini temperature: 0 max_tokens: 500你在接口层做的事情其实不复杂:把不同模型的参数差异封装掉,比如有的模型叫max_tokens,有的叫max_completion_tokens;有的是流式输出,有的不走流式;有的支持JSON mode,有的不支持。统一接口的意义在于:上面业务逻辑只认你自己的参数签名,下面模型随便换,完全不影响上层。
我实际开发里还会加一个“调用模式开关”:
fast_mode:用便宜小模型处理分类、抽取、格式化任务。normal_mode:用主力模型处理生成和推理任务。heavy_mode:用长上下文模型处理大规模文档分析。
这个开关是控制成本的重中之重。纯靠一个强大模型跑所有任务,成本会线性失控;而把任务拆细,用不同性能和价格的模型去匹配,效果和成本都能兼顾。
3.2 Prompt版本管理与“可回滚”能力
做AI工程之后,我养成了把Prompt当代码管理的习惯。每个Prompt都有版本号、有提交记录、有对应的评测结果。为什么?因为模型在升级,业务在调整,Prompt在不经意间可能就漂移了。你今天改了一个措辞,感觉结果还行,但没记录改了什么;下周效果变差,你想回退都不知道退到哪一版。
我常用的目录结构是这样:
prompts/ analysis/ v1_initial.yaml v2_add_fewshot.yaml tools/ v1_selector.yaml output/ v1_json_schema.yaml每个Prompt文件不仅写内容,还在头部维护元信息:
version: v2 model: gpt-4o-mini temperature: 0 last_eval_score: 0.94 change_log: 增加了一个少样本示例,处理“价格查询”意图不要小看这个做法。你只有量化对比,才知道某个Prompt改动是正向还是负向。所有Prompt都选定一个CURRENT指针,方便快速切换版本。上线之后如果效果回退,一键恢复到上一版。这比在代码里硬编码字符串要科学得多。
3.3 输出结构:JSON Schema不是可选项,是必修课
模型输出最大的问题是不可控。你说让它“返回一个包含城市和天气的JSON”,它可能给你返回带Markdown代码块的内容,也可能给出多余的说明文字。所以我在接口层强制做了解构校验。
先定Schema:
from pydantic import BaseModel class WeatherResponse(BaseModel): city: str date: str temperature: float unit: str = "celsius" class ToolPlan(BaseModel): thought: str use_tool: bool tool_name: str | None args: dict然后在Prompt里给模型极简且明确的指令,比如:
你是一个工具规划器。请根据用户的问题,判断是否需要调用工具。 只输出JSON,不要输出任何解释。必须符合如下格式: {"thought": "...", "use_tool": true, "tool_name": "weather", "args": {"city": "北京"}}收到输出后,第一步不是直接解析,而是先做清洗:去掉可能包裹的Markdown代码块、修复未转义的引号。接着再用Pydantic做严格校验。如果校验失败,我会触发重试,最多两次。超过两次就进入兜底流程,比如返回“暂时无法处理,请换一种问法”。
这套流程看着笨,但它能挡住生产环境中大量低级错误。那些跑到线上才发现“模型返回的JSON解析失败了”的惨案,多半是省略了这层约束和校验。
4. 工具调用:让模型从“会聊天”到“能办事”
4.1 工具注册机制与参数收敛
AI应用不接工具,就像让一个知识渊博的人闭着眼睛回答问题。但要接工具,你面临的就不仅是“调用API”,而是模型怎么准确选择工具、怎么填对参数。我的做法是做一个工具注册表:
TOOL_REGISTRY = {} def register_tool(name: str, description: str, parameters_schema: dict): def decorator(func): TOOL_REGISTRY[name] = { "func": func, "description": description, "parameters_schema": parameters_schema, } return func return decorator @register_tool( name="get_stock_price", description="查询某只股票的最新价格,输入为股票代码", parameters_schema={ "symbol": {"type": "string", "description": "股票代码,如000001"}, }, ) def get_stock_price(symbol: str): ...每个工具的描述必须能概括它“什么时候该用、什么时候不该用”。我发现模型选错工具的很大原因不是能力不行,而是工具描述写得模糊。比如一个叫search_docs的工具,描述只写“搜索文档”是远远不够的,应该写“当你需要查找内部产品手册、FAQ、售后规范时使用;不要用于搜索实时股价,股价请用 get_stock_price”。工具描述越接近“决策边界”,模型选得越准。
4.2 工具结果太长怎么办
实际跑下来,最影响模型回答质量的因素不是Prompt写得不好,而是工具返回结果塞爆了上下文。如果你把一个数据库表3000行记录全部塞回给模型,它会直接“迷失”在长文本里,回答会退化成复读机。
我的处理策略是分层压缩:
- 第一层:工具层尽力返回结构化摘要,比如只返回Top 10结果和统计指标。
- 第二层:把完整结果控制在2000 Token以内,超出部分先做截断。
- 第三层:若是检索类结果,先做相关度重排,只保留跟用户问题最相关的段落。
我还设置了一个变压器的小模块:模型给出的查询、工具返回的内容,全部要经过一次“压缩和重排”。这样进入最终生成阶段的数据,信息密度足够高,模型才能给出有洞察的回答。
4.3 有限步骤终止:防止Agent无限循环
让模型自由决定“下一步做什么”是很危险的。我最早调试时,出现过模型连续调用同一个搜索工具十几遍,整个会话不可控。后来项目里强制设置了最大步骤数3到5步,每轮完成后都要求模型输出is_finished: true/false。一旦超过上限,强制终止并生成兜底回答。
同时我会给每轮工具调用做审计日志:记录了调用顺序、输入输出摘要、Token消耗、耗时。有了审计日志,排查线上问题时才能快速定位“到底是哪一步走错了”。没有日志的AI系统,等于开车没录像,出车祸只能靠猜。
5. 评测、成本与安全:AI工程的三大隐藏支柱
5.1 用一套评测集守住效果底线
不做评测的AI工程,优化就是开盲盒。我维护了一套非常小的种子评测集,但每次迭代都会跑。评测集包含三类样本:
- 黄金样例:有标准答案,用于跑准确率。
- 边界样例:比如用户问题含混不清、工具查询结果为空,用于检查兜底逻辑。
- 稳定性样例:同样的问题问两次,看输出是否会剧烈抖动。
实际跑评测时,我还在代码里自动对比前后两次的模型输出,提取差异点。如果某些Prompt改动让核心指标下降了,代码会直接输出告警,而不是等它在线上用户那里暴雷。
评测集的规模不用大,50到80条就够日常迭代。关键是样本要覆盖业务的“最小可接受场景”。等你的系统稳定了,再把评测集扩充到几百条,引入更细的维度,比如答案是否利用了工具信息、引用是否准确、格式是否符合预期。
5.2 Token成本核算:别等到月底看账单才慌
我见过太多团队:Demo阶段跑得飞起,上线之后每个月账单吓死人,才发现根本没有成本控制。AI工程的成本,从来不是“模型贵不贵”的问题,而是“你让模型干了多少不该它干的活”。
我的成本控制三板斧:
- 第一板斧:能用小模型绝不用大模型。分类、命名实体识别、抽取,这些任务用小模型就够了。只有复杂推理、长文总结、代码生成才上大模型。
- 第二板斧:Prompt别塞无关上下文。很多人喜欢把所有历史对话一股脑塞进请求,结果Token数量爆炸。我的做法是:多轮对话存到向量库,只检索相关片段放回上下文,旧对话默认不参与计算。
- 第三板斧:优先用流式输出和缓存。稳定的高频问题可以直接走缓存,不再重复调用模型。设置好每用户每月的预算上限,超了就降级到备用模型或简化服务。
我在项目里写了一个简单的成本统计装饰器,每次调用都会记录prompt_tokens,completion_tokens, 估算金额,并写入日志。这样一天结束后,你能按用户、按功能、按模型粒度去看成本分布,而不是月底看到一个总账单傻眼。
5.3 输出安全与敏感信息过滤
这是所有AI应用上线前必须过的一关。模型不是系统,它不认你的红线规则。我强烈建议在接口层做两层过滤:
- 输入侧:检测用户是否上传了不该上传的内容,对隐私信息做脱敏。
- 输出侧:对模型生成的文本做关键词和模式匹配,防止泄露手机号、身份证号、银行卡号等敏感数据。
此外,工具调用参数也要校验。用户可能通过Prompt注入让模型调用危险参数,比如让搜索工具去访问一个内网地址。所以每个工具接收参数前,都必须做白名单校验。别以为模型会很聪明地防守,绝大多数情况下它只是个“鹦鹉”,用户绕两句话它就照做了。防线必须写在代码里,而不是指望模型自觉。
6. 常见问题与排查经验速查
6.1 高频故障及对策表
| 症状 | 常见原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| 模型频繁返回JSON解析失败 | 提示词约束不足、温度过高 | 查看原始返回,判断是多了前缀还是格式错误 | 提高输出约束强度、降低temperature、加清洗层 |
| 工具调用名称经常选错 | 工具描述不清晰,或工具数量太多 | 统计一次请求中所有工具描述占用的Token,查看模型是否“看不过来” | 精简工具描述,按场景分组,或先做意图路由 |
| 多轮对话出现上下文遗忘 | 旧会话全量塞入,导致注意力被稀释 | 检查上下文Token构成 | 引入摘要记忆或向量检索,只保留高价值片段 |
| 效果忽好忽坏,无规律 | Prompt版本混乱,或模型路由参数不一致 | 对比评测集输出,看飘移是否集中在某些样例 | 统一模型版本,Prompt纳入版本管理 |
| 成本快速上涨 | 大量长上下文重复请求 | 按功能维度统计Token消耗 | 引入缓存、压缩、小模型分流 |
| Agent陷入死循环 | 缺少步骤上限和终止条件 | 查看审计日志中的调用链 | 最大步骤限制,强制终止,兜底回答 |
6.2 心态与调试习惯:比技术更重要的几条原则
AI工程调试和传统软件开发最大的不同是:错误往往不是“报错”,而是“沉默地给出一个不准确结果”。如果你没有建立基线评测,你连“什么时候变坏了”都发现不了。
我的调试习惯是:
- 任何改动只改一个变量。改Prompt就不动代码,改代码就不动Prompt。
- 每次实验前先挑三个边界样例,快速跑一遍,看是否触雷。
- 手工调好后,把样例补进自动化评测集,锁住迭代基线。
- 遇到“怎么调都不行”的局面,不要硬调Prompt,先思考问题本身是不是需要换模型、加工具或改流程。
一个词总结就是纪律。AI工程最大的坑不是模型不行,而是你自己对系统失去了控制。只要你用版本管理、评测集、审计日志把闭环建立起来,出现的每一个问题都可以定位、修复和回归;没有这套闭环,AI项目做一年也还是在打地鼠。
7. 建这个项目之后,我对“From Scratch”的重新理解
刚开始做“ai-engineering-from-scratch”时,我以为重点在“工程实现”,就是把代码写工整、把模块拆清楚。后来跑完整个项目,我更愿意把“From Scratch”看成一种工程态度:不盲目相信模型替你搞定一切,不盲目相信框架替你藏掉所有细节,也不盲目相信调参能解决所有问题。
我最深的一点体会是:模型能力会持续升级,框架会不断更迭,但“可控、可测、可回滚、可观测”这套工程原则不会过时。你掌握的这些底层能力,才是从众多“会接API的人”里真正拉开差距的地方。
如果你现在正准备搭自己的AI应用,我的建议是从一个很小的闭环开始:一个模型、一个Prompt、一个工具、一个JSON输出,加一套自动化评测。先把它做到稳定,再慢慢加复杂度。后面如果想深入了解工具调用、评测体系或者成本控制,完全可以在我这个主干上继续扩展——但地基打得稳,楼上盖多高都不慌。